diff options
549 files changed, 42158 insertions, 3978 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index bc574d7054..e7d991d94a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.10", + "version": "10.0.11", "commands": [ "dotnet-ef" ] diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 7a00dedbff..12ee6411d3 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,12 +87,9 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: + - 12.1 + - 12.0 - 10.11.11 - - 10.11.10 - - 10.11.9 - - 10.11.8 - - 10.11.7 - - 10.11.6 - Master - Unstable - Older* diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index bc7ef7f5da..2b51105652 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -32,13 +32,13 @@ jobs: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index f2c0a5c4cd..bf28129368 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -11,7 +11,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -40,7 +40,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml index 4531c824ce..73c8e7e50c 100644 --- a/.github/workflows/ci-format.yml +++ b/.github/workflows/ci-format.yml @@ -15,7 +15,7 @@ jobs: format-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f8704fb1eb..7fc22ed001 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -20,7 +20,7 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -35,7 +35,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10 + uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 6a0d7caa2e..d14ce6d508 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -40,12 +40,12 @@ jobs: runs-on: ubuntu-latest steps: - name: pull in script - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' cache: 'pip' diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml index 9adac6b995..2ad326e894 100644 --- a/.github/workflows/issue-stale.yml +++ b/.github/workflows/issue-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml index 9dec170d70..3220bcbeac 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -10,12 +10,12 @@ jobs: issues: write steps: - name: pull in script - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' cache: 'pip' diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml index 09ab727c15..48bd8adb03 100644 --- a/.github/workflows/openapi-generate.yml +++ b/.github/workflows/openapi-generate.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref }} repository: ${{ inputs.repository }} diff --git a/.github/workflows/openapi-pull-request.yml b/.github/workflows/openapi-pull-request.yml index 6b5d035420..9ed1873942 100644 --- a/.github/workflows/openapi-pull-request.yml +++ b/.github/workflows/openapi-pull-request.yml @@ -10,7 +10,7 @@ jobs: base_ref: ${{ steps.ancestor.outputs.base_ref }} steps: - name: Checkout Repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 9a9f3214a7..5d7dd09348 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -1,66 +1,29 @@ name: Project Automation on: - push: - branches: - - master pull_request: - issue_comment: + types: [opened, reopened, synchronize, ready_for_review, edited] + pull_request_review: + types: [submitted] + pull_request_target: + types: [closed] + +permissions: + contents: read -permissions: {} jobs: - project: - name: Project board + project-board: + name: Release project board runs-on: ubuntu-latest - if: ${{ github.repository == 'jellyfin/jellyfin' }} + if: ${{ github.repository == 'jellyfin/jellyfin' && (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) }} steps: - - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') - continue-on-error: true - with: - project: Current Release - action: delete - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' - continue-on-error: true - with: - project: Release Next - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') - continue-on-error: true - with: - project: Current Release - column: In progress - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Check number of comments from the team member - if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' - id: member_comments - run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - - - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 - continue-on-error: true - with: - project: Issue Triage for Main Repo - column: Needs triage - repo-token: ${{ secrets.JF_BOT_TOKEN }} - - - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0 - if: github.event.issue.pull_request == '' && github.event.action == 'opened' - continue-on-error: true + - name: Update project board + uses: jellyfin/jellyfin-packaging/.github/actions/project-automation@master with: - project: Issue Triage for Main Repo - column: Pending response - repo-token: ${{ secrets.JF_BOT_TOKEN }} + github-token: ${{ secrets.GH_PROJECTS_TOKEN }} + repo: jellyfin + event-name: ${{ github.event_name }} + base-ref: ${{ github.event.pull_request.base.ref }} + pr-number: ${{ github.event.pull_request.number }} + pr-merged: ${{ github.event.pull_request.merged }} + required-approvals: '2' diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index f92a9be9d5..2f1a8c7dce 100644 --- a/.github/workflows/pull-request-stale.yaml +++ b/.github/workflows/pull-request-stale.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml index d22e8bf6bd..4d6e1ad6fa 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG_BRANCH }} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4e323e332a..f5f844bcbc 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -138,11 +138,13 @@ - [SegiH](https://github.com/SegiH) - [SenorSmartyPants](https://github.com/SenorSmartyPants) - [shemanaev](https://github.com/shemanaev) + - [SimonvBez](https://github.com/SimonvBez) - [skaro13](https://github.com/skaro13) - [sl1288](https://github.com/sl1288) - [Smith00101010](https://github.com/Smith00101010) - [sorinyo2004](https://github.com/sorinyo2004) - [Soumyadip Auddy](https://github.com/SoumyadipAuddy) + - [st7105](https://github.com/st7105) - [sparky8251](https://github.com/sparky8251) - [spookbits](https://github.com/spookbits) - [ssenart](https://github.com/ssenart) @@ -172,6 +174,7 @@ - [whooo](https://github.com/whooo) - [WiiPlayer2](https://github.com/WiiPlayer2) - [WillWill56](https://github.com/WillWill56) + - [WizardOfYendor1](https://github.com/WizardOfYendor1) - [wtayl0r](https://github.com/wtayl0r) - [Wuerfelbecher](https://github.com/Wuerfelbecher) - [Wunax](https://github.com/Wunax) @@ -233,6 +236,12 @@ - [MSalman5230](https://github.com/MSalman5230) - [dwandw](https://github.com/dwandw) - [Lampan-git](https://github.com/Lampan-git) + - [elio42](https://github.com/elio42) + - [rwebster85](https://github.com/rwebster85) + - [Florin-Popescu](https://github.com/Florin-Popescu) + - [m0g3r](https://github.com/m0g3r) + - [martin-77](https://github.com/martin-77) + - [Oggeb1](https://github.com/Oggeb1) # Emby Contributors @@ -243,7 +252,7 @@ - [Mark2xv](https://github.com/Mark2xv) - [ScottRapsey](https://github.com/ScottRapsey) - [skynet600](https://github.com/skynet600) - - [Cheesegeezer](https://githum.com/Cheesegeezer) + - [Cheesegeezer](https://github.com/Cheesegeezer) - [Radeon](https://github.com/radeonorama) - [gcw07](https://github.com/gcw07) - [SivaramAdhiappan](https://github.com/shivaram1190) diff --git a/Directory.Packages.props b/Directory.Packages.props index cb02433c06..38cf513124 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ <PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" /> <PackageVersion Include="AutoFixture" Version="4.18.1" /> <PackageVersion Include="BDInfo" Version="0.8.0" /> - <PackageVersion Include="BitFaster.Caching" Version="2.6.0" /> + <PackageVersion Include="BitFaster.Caching" Version="2.6.1" /> <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="CommandLineParser" Version="2.9.1" /> @@ -17,7 +17,7 @@ <PackageVersion Include="Diacritics" Version="4.1.8" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> - <PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" /> + <PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" /> <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" /> <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" /> @@ -26,33 +26,34 @@ <PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" /> - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> + <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" /> <PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="Morestachio" Version="5.0.1.670" /> <PackageVersion Include="Moq" Version="4.18.4" /> <PackageVersion Include="NEbml" Version="1.1.0.5" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.4" /> + <PackageVersion Include="PDFtoImage" Version="5.2.1" /> <PackageVersion Include="PlaylistsNET" Version="1.4.1" /> <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> @@ -67,7 +68,7 @@ <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> - <PackageVersion Include="SharpCompress" Version="0.49.1" /> + <PackageVersion Include="SharpCompress" Version="0.50.4" /> <PackageVersion Include="SharpFuzz" Version="2.3.0" /> <PackageVersion Include="SkiaSharp" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> @@ -77,11 +78,11 @@ <PackageVersion Include="Svg.Skia" Version="3.7.0" /> <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" /> - <PackageVersion Include="System.Text.Json" Version="10.0.10" /> + <PackageVersion Include="System.Text.Json" Version="10.0.11" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="7.15.3" /> + <PackageVersion Include="z440.atl.core" Version="7.16.0" /> <PackageVersion Include="TMDbLib" Version="3.0.0" /> - <PackageVersion Include="UTF.Unknown" Version="2.6.0" /> + <PackageVersion Include="UTF.Unknown" Version="2.7.0" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="Xunit.v3.Priority" Version="1.1.18" /> diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 21638ba9e7..9ecefb4c7b 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -57,7 +57,6 @@ namespace Emby.Naming.Common ".nrg", ".nsv", ".nuv", - ".ogg", ".ogm", ".ogv", ".pva", @@ -152,7 +151,7 @@ namespace Emby.Naming.Common CleanStrings = [ - @"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)", + @"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|web-dl|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS)(?=[ _\,\.\(\)\[\]\-]|$)", @"^\s*(?<cleaned>.+?)((\s*\[[^\]]+\]\s*)+)(\.[^\s]+)?$", @"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)", @@ -362,7 +361,10 @@ namespace Emby.Naming.Common // Not a Kodi rule as well, but the expression below also causes false positives, // so we make sure this one gets tested first. // "Foo Bar 889" - new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$") + // Names carrying an SxxEyy marker are excluded because the Kodi expression above already covers them. + // Without that guard this expression reads digits out of the title instead, turning + // "S01E01 1-23-45 [Bluray-1080p]" into episodes 1 through 45. + new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?![^\\\/]*[Ss][0-9]+[][ ._-]*[Ee][0-9]+)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$") { IsNamed = true }, diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 9f98970df5..acf43a69c2 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -36,7 +36,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Naming</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 8e7da5db42..1f16161282 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles } var extension = Path.GetExtension(path.AsSpan()); - if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + + // .idx carries VobSub per-track language metadata. Recognize it here rather + // than adding it to NamingOptions.SubtitleFileExtensions, which also gates + // subtitle uploads/saves. + var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase); + + if (!isVobSubIndex + && !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) && !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) { diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index 0c737964b4..f06aa909ba 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -158,7 +158,9 @@ namespace Emby.Naming.TV if (nextIndex >= name.Length || !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal)) { - if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + // A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range. + if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num) + && num >= result.EpisodeNumber) { result.EndingEpisodeNumber = num; } @@ -226,7 +228,7 @@ namespace Emby.Naming.TV info.SeriesName = result.SeriesName; } - if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue) + if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber) { info.EndingEpisodeNumber = result.EndingEpisodeNumber; } diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index 733e2418c2..ce42cb69fb 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -10,11 +10,14 @@ namespace Emby.Naming.TV public static partial class SeriesResolver { /// <summary> - /// Regex that matches strings of at least 2 characters separated by a dot or underscore. - /// Used for removing separators between words, i.e turns "The_show" into "The show" while - /// preserving names like "S.H.O.W". + /// Regex that matches a run of dots or underscores that separates two words, where a word is + /// at least 2 characters long. Used for removing separators between words, i.e turns + /// "The_show" into "The show" while preserving acronyms like "S.H.O.W", whose single letters + /// are a word on neither side. Whitespace bounds a word too, so the dot in + /// "Marvel's Agents of S.H.I.E.L.D." is read against the "S" beside it rather than against + /// the whole run of words before it. /// </summary> - [GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")] + [GeneratedRegex(@"(?<=[^\s\._]{2})[\._]+|[\._]+(?=[^\s\._]{2})")] private static partial Regex SeriesNameRegex(); /// <summary> @@ -60,7 +63,7 @@ namespace Emby.Naming.TV if (!string.IsNullOrEmpty(seriesName)) { - seriesName = SeriesNameRegex().Replace(seriesName, "${a} ${b}").Trim(); + seriesName = SeriesNameRegex().Replace(seriesName, " ").Trim(); } return new SeriesInfo(path) diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index eb5e71d78f..a287a2525b 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -52,13 +52,18 @@ namespace Emby.Naming.Video while (path.Length > 0) { var index = path.IndexOfAny(delimiters); + ReadOnlySpan<char> currentSlice; if (index == -1) { - index = path.Length - 1; + // No delimiter left, the last token is the remainder of the path + currentSlice = path; + path = default; + } + else + { + currentSlice = path[..index]; + path = path[(index + 1)..]; } - - var currentSlice = path[..index]; - path = path[(index + 1)..]; if (!foundPrefix) { diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 29330b132d..e16562774d 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -217,24 +217,7 @@ namespace Emby.Naming.Video for (var i = 0; i < videos.Count; i++) { var video = videos[i]; - var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false); - string? key = null; - if (episodeResult.Success) - { - if (episodeResult.IsByDate - && episodeResult.Year.HasValue - && episodeResult.Month.HasValue - && episodeResult.Day.HasValue) - { - key = FormattableString.Invariant( - $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}"); - } - else if (episodeResult.EpisodeNumber.HasValue) - { - key = FormattableString.Invariant( - $"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}"); - } - } + var key = GetEpisodeVersionKey(video.Files[0].Path); if (key is null) { @@ -265,6 +248,29 @@ namespace Emby.Naming.Video return result; } + private string? GetEpisodeVersionKey(string path) + { + // Optimistic expressions are guesses, so they are not consulted here: merging is destructive, + // a file collapsed into the alternate versions of another one is no longer an episode of its own. + var episodeResult = _episodePathParser.Parse(path, false, isOptimistic: false, fillExtendedInfo: false); + if (!episodeResult.Success) + { + return null; + } + + if (episodeResult.IsByDate) + { + return episodeResult.Year.HasValue && episodeResult.Month.HasValue && episodeResult.Day.HasValue + ? FormattableString.Invariant( + $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}") + : null; + } + + return episodeResult.SeasonNumber.HasValue && episodeResult.EpisodeNumber.HasValue + ? FormattableString.Invariant($"S{episodeResult.SeasonNumber.Value}E{episodeResult.EpisodeNumber.Value}") + : null; + } + private static VideoInfo OrganizeAlternateVersions( List<VideoInfo> videos, VideoInfo? primaryOverride = null, diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 69e23bcb63..1a54565863 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Drawing; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Manager; @@ -417,6 +419,8 @@ namespace Emby.Server.Implementations { Logger.LogInformation("Running startup tasks"); + EnsureStartupWizardIntegrity(); + Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; @@ -436,6 +440,24 @@ namespace Emby.Server.Implementations return Task.CompletedTask; } + private void EnsureStartupWizardIntegrity() + { + if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + return; + } + + var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers() + .Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password)); + + if (hasConfiguredAdministrator) + { + Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable."); + ConfigurationManager.Configuration.IsStartupWizardCompleted = true; + ConfigurationManager.SaveConfiguration(); + } + } + /// <inheritdoc/> public void Init(IServiceCollection serviceCollection) { @@ -965,8 +987,9 @@ namespace Emby.Server.Implementations /// <inheritdoc/> public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + // If the smartAPI isn't already a complete URL then treat it as a host or ip. + if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return hostname.TrimEnd('/'); } diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 295efd456c..cd93f76d26 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -9,6 +9,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; @@ -107,13 +108,14 @@ namespace Emby.Server.Implementations.Collections SaveLocalMetadata = true }; - var name = _localizationManager.GetLocalizedString("Collections"); + // This names a library for the whole server, so ignore the requesting client's language. + var name = _localizationManager.GetServerLocalizedString("Collections"); await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false); _libraryManager.RootFolder.Children = null; - return FindFolders(path).First(); + return FindFolders(path).FirstOrDefault(); } internal string GetCollectionsFolderPath() @@ -166,7 +168,7 @@ namespace Emby.Server.Implementations.Collections if (parentFolder is null) { - throw new ArgumentException(nameof(parentFolder)); + throw new InvalidOperationException("Unable to resolve the collections library folder, so the collection cannot be created."); } var path = Path.Combine(parentFolder.Path, folderName); @@ -234,7 +236,7 @@ namespace Emby.Server.Implementations.Collections List<BaseItem>? itemList = null; - var linkedChildrenList = collection.GetLinkedChildren(); + var linkedChildrenList = collection.GetLinkedChildren(DtoOptions.StoredColumnsOnly); var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList(); foreach (var id in ids) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 8cbf42585d..e539508644 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; @@ -185,15 +186,18 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); } - // Batch-fetch child counts for all folders to avoid N+1 queries + // Batch-fetch by-name item counts to avoid N+1 queries + Dictionary<Guid, ItemCounts>? itemCountsBatch = null; + if (options.ContainsField(ItemFields.ItemCounts)) + { + itemCountsBatch = GetItemCountsBatch(accessibleItems, user); + } + + // Batch-fetch child counts for all folders to avoid N+1 queries. Dictionary<Guid, int>? childCountBatch = null; - if (options.ContainsField(ItemFields.ChildCount)) + if (user is not null && options.ContainsField(ItemFields.ChildCount)) { - var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList(); - if (folderIds.Count > 0) - { - childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id); - } + childCountBatch = GetChildCountBatch(accessibleItems, user); } // Batch-fetch played/total counts for all folders to avoid N+1 queries @@ -242,6 +246,29 @@ namespace Emby.Server.Implementations.Dto artistsBatch = _libraryManager.GetArtists(artistNames.ToArray()); } + // Batch-fetch people across all items to avoid one GetPeople query per item. + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null; + if (options.ContainsField(ItemFields.People)) + { + var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList(); + if (peopleItemIds.Count > 0) + { + peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds); + } + } + + // Batch-detect which videos own alternate versions to avoid the per-item alternate-version + // queries in MediaSourceCount. Videos absent from this set have a single media source. + IReadOnlySet<Guid>? alternateVersionItemIds = null; + if (options.ContainsField(ItemFields.MediaSourceCount)) + { + var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList(); + if (versionItemIds.Count > 0) + { + alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds); + } + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; @@ -255,7 +282,9 @@ namespace Emby.Server.Implementations.Dto childCountBatch, playedCountBatch, artistsBatch, - resumeDataBatch?.GetValueOrDefault(item.Id)); + resumeDataBatch?.GetValueOrDefault(item.Id), + peopleBatch, + alternateVersionItemIds); if (item is LiveTvChannel tvChannel) { @@ -268,7 +297,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ItemCounts)) { - SetItemByNameInfo(dto, user); + SetItemByNameInfo(dto, user, itemCountsBatch); } returnItems[index] = dto; @@ -317,7 +346,9 @@ namespace Emby.Server.Implementations.Dto Dictionary<Guid, int>? childCountBatch = null, Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, - VersionResumeData? resumeData = null) + VersionResumeData? resumeData = null, + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null, + IReadOnlySet<Guid>? alternateVersionItemIds = null) { var dto = new BaseItemDto { @@ -331,7 +362,15 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.People)) { - AttachPeople(dto, item, user); + IReadOnlyList<PersonInfo>? prefetchedPeople = null; + if (peopleBatch is not null) + { + // The batch omits items with no people, so a miss means "no people", + // not "not fetched". Use an empty list to skip the per-item query. + prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? []; + } + + AttachPeople(dto, item, user, prefetchedPeople); } if (options.ContainsField(ItemFields.PrimaryImageAspectRatio)) @@ -378,7 +417,7 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options, artistsBatch, user); + AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds); if (options.ContainsField(ItemFields.CanDelete)) { @@ -483,14 +522,36 @@ namespace Emby.Server.Implementations.Dto return dto; } - private void SetItemByNameInfo(BaseItemDto dto, User? user) + private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user) + { + var result = new Dictionary<Guid, ItemCounts>(); + + foreach (var group in items.GroupBy(item => item.GetBaseItemKind())) + { + if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds)) + { + continue; + } + + var ids = group.Select(item => item.Id).ToArray(); + foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user)) + { + result[id] = counts; + } + } + + return result; + } + + private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null) { if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds)) { return; } - var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); + var counts = prefetchedCounts?.GetValueOrDefault(dto.Id) + ?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user); dto.AlbumCount = counts.AlbumCount; dto.ArtistCount = counts.ArtistCount; @@ -576,7 +637,11 @@ namespace Emby.Server.Implementations.Dto // For these types we can try to optimize and assume these values will be equal if (item is MusicAlbum || item is Season || item is Playlist) { - dto.ChildCount = dto.RecursiveItemCount; + if (dto.RecursiveItemCount > 0) + { + dto.ChildCount = dto.RecursiveItemCount; + } + var folderChildCount = folder.LinkedChildren.Length; // The default is an empty array, so we can't reliably use the count when it's empty if (folderChildCount > 0) @@ -646,23 +711,102 @@ namespace Emby.Server.Implementations.Dto }; } - private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch) + private Dictionary<Guid, int>? GetChildCountBatch(IReadOnlyList<BaseItem> items, User user) { - // Right now this is too slow to calculate for top level folders on a per-user basis - // Just return something so that apps that are expecting a value won't think the folders are empty - if (folder is ICollectionFolder || folder is UserView) + Dictionary<Guid, IReadOnlyList<Guid>>? sources = null; + foreach (var folder in items.OfType<Folder>()) { - return Random.Shared.Next(1, 10); + var sourceIds = GetChildCountSourceIds(folder, user); + if (sourceIds.Count > 0) + { + (sources ??= new Dictionary<Guid, IReadOnlyList<Guid>>())[folder.Id] = sourceIds; + } } + if (sources is null) + { + return null; + } + + var counts = _libraryManager.GetChildCountBatch( + sources.Values.SelectMany(ids => ids).Distinct().ToList(), + user); + + var result = new Dictionary<Guid, int>(sources.Count); + foreach (var (folderId, sourceIds) in sources) + { + var total = 0; + foreach (var sourceId in sourceIds) + { + total += counts.GetValueOrDefault(sourceId); + } + + result[folderId] = total; + } + + return result; + } + + private IReadOnlyList<Guid> GetChildCountSourceIds(Folder folder, User user) + { + if (folder is CollectionFolder collectionFolder) + { + return collectionFolder.PhysicalFolderIds; + } + + if (folder is not UserView view) + { + return [folder.Id]; + } + + // Only a view that stands for a library proxies it. The sub-views a movie or show view + // is built from hang off the same library but hold a query, not the library's children. + if (!UserView.EnableOriginalFolder(view.ViewType) + && view.ViewType is not (CollectionType.movies or CollectionType.tvshows)) + { + return []; + } + + // A view over a single library proxies that library, whatever the view type. + var parentId = view.DisplayParentId.IsEmpty() ? view.ParentId : view.DisplayParentId; + if (!parentId.IsEmpty() + && !parentId.Equals(view.Id) + && _libraryManager.GetItemById(parentId) is Folder parent + && parent is not UserView) + { + return GetChildCountSourceIds(parent, user); + } + + // A grouped view has no single parent: it stands for every library the user grouped + // into it, the same set UserViewManager builds the view from. + if (view.ViewType is CollectionType.movies or CollectionType.tvshows) + { + return _libraryManager.GetUserRootFolder() + .GetChildren(user, true) + .OfType<CollectionFolder>() + .Where(f => user.IsFolderGrouped(f.Id) + && (f.CollectionType == view.ViewType || f.CollectionType is null)) + .SelectMany(f => f.PhysicalFolderIds) + .Distinct() + .ToList(); + } + + return []; + } + + private int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch) + { // Use pre-fetched batch data if available if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count)) { return count; } - // Fall back to individual query for special cases (Series, Season, etc.) - return folder.GetChildCount(user); + // No batch covered this folder. + var single = GetChildCountBatch([folder], user); + return single is not null && single.TryGetValue(folder.Id, out var singleCount) + ? singleCount + : folder.GetChildCount(user); } private static void SetBookProperties(BaseItemDto dto, Book item) @@ -742,12 +886,18 @@ namespace Emby.Server.Implementations.Dto /// <param name="dto">The dto.</param> /// <param name="item">The item.</param> /// <param name="user">The requesting user.</param> - private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null) + /// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param> + private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null) { + // When rendering a page of items the caller batch-fetches people for every item up + // front and passes them in, avoiding one GetPeople query per item. Fall back to the + // per-item query for the single item path where no batch is available. + var source = prefetchedPeople ?? _libraryManager.GetPeople(item); + // Ordering by person type to ensure actors and artists are at the front. // This is taking advantage of the fact that they both begin with A // This should be improved in the future - var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue) + var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue) .ThenBy(i => { if (i.IsType(PersonKind.Actor)) @@ -957,7 +1107,8 @@ namespace Emby.Server.Implementations.Dto /// <param name="options">The options.</param> /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> /// <param name="user">The user, for per-user values such as the accessible media source count.</param> - private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null) + /// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param> + private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -1088,7 +1239,7 @@ namespace Emby.Server.Implementations.Dto dto.ParentId = item.DisplayParentId; } - AddInheritedImages(dto, item, options, owner); + AddInheritedImages(dto, item, options, owner, artistsBatch); if (options.ContainsField(ItemFields.Path)) { @@ -1271,15 +1422,27 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaSourceCount)) { - // Match the per-user filtering of the media sources: versions the user cannot - // access are not selectable, so they must not count towards the badge either. - var mediaSourceCount = user is null - || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions) - ? video.MediaSourceCount - : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); - if (mediaSourceCount != 1) + // A video with no primary version and no alternate versions always has a single + // media source. Only compute the count for videos that might have more: a primary + // version, or membership in the batch's set of items that own alternate versions. + // Without the batch we can't rule it out, so fall back to computing (the single-item + // path). Everything else is the common case and keeps the default count of one. + var mayHaveAlternateVersions = alternateVersionItemIds is null + || video.PrimaryVersionId.HasValue + || alternateVersionItemIds.Contains(video.Id); + + if (mayHaveAlternateVersions) { - dto.MediaSourceCount = mediaSourceCount; + // Match the per-user filtering of the media sources: versions the user cannot + // access are not selectable, so they must not count towards the badge either. + var mediaSourceCount = user is null + || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions) + ? video.MediaSourceCount + : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user)); + if (mediaSourceCount != 1) + { + dto.MediaSourceCount = mediaSourceCount; + } } } @@ -1519,11 +1682,11 @@ namespace Emby.Server.Implementations.Dto } } - private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem) + private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) { if (currentItem is MusicAlbum musicAlbum) { - var artist = musicAlbum.GetMusicArtist(new DtoOptions(false)); + var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false)); if (artist is not null) { return artist; @@ -1540,7 +1703,20 @@ namespace Emby.Server.Implementations.Dto return parent; } - private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner) + private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) + { + if (artistsBatch is null) + { + return null; + } + + var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null; + return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0 + ? artists[0] + : null; + } + + private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch) { if (item is UserView { ViewType: CollectionType.playlists } playlistsView && options.GetImageLimit(ImageType.Primary) > 0 @@ -1585,7 +1761,7 @@ namespace Emby.Server.Implementations.Dto || (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || parent is Series) { - parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent; + parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent; if (parent is null) { break; @@ -1644,7 +1820,7 @@ namespace Emby.Server.Implementations.Dto break; } - parent = GetImageDisplayParent(parent, item); + parent = GetImageDisplayParent(parent, item, artistsBatch); } } diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 933cfc8cbe..02b104756e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints; /// </summary> public sealed class LibraryChangedNotifier : IHostedService, IDisposable { + // A batch holds a live reference to every item it names, so it has to stay small enough that a + // library scan - which changes items faster than any batch window closes - cannot grow it without + // bound. Reached only by a scan; interactive use closes a batch on the window long before this. + internal const int MaxBatchSize = 2000; + private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IProviderManager _providerManager; @@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private readonly ILogger<LibraryChangedNotifier> _logger; private readonly Lock _libraryChangedSyncLock = new(); - private readonly List<Folder> _foldersAddedTo = new(); - private readonly List<Folder> _foldersRemovedFrom = new(); - private readonly List<BaseItem> _itemsAdded = new(); - private readonly List<BaseItem> _itemsRemoved = new(); - private readonly List<BaseItem> _itemsUpdated = new(); + private readonly Dictionary<Guid, Folder> _foldersAddedTo = []; + private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = []; + private readonly Dictionary<Guid, BaseItem> _itemsAdded = []; + private readonly Dictionary<Guid, BaseItem> _itemsRemoved = []; + private readonly Dictionary<Guid, BaseItem> _itemsUpdated = []; private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new(); private Timer? _libraryUpdateTimer; @@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e) => OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom); - private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList) + private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList) { if (!FilterItem(item)) { @@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable lock (_libraryChangedSyncLock) { - var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); - + // The window runs from the first change of a batch and is never extended. Extending it on + // every change would keep a library scan's batch open for the whole scan, and the batch + // holds the items it names alive, so it would grow to the size of the library. if (_libraryUpdateTimer is null) { + var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration); _libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan); } - else - { - _libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan); - } if (foldersList is not null && parent is Folder folder) { - foldersList.Add(folder); + foldersList[folder.Id] = folder; } - itemsList.Add(item); + itemsList[item.Id] = item; + + // A window long enough to cover a burst still has to give way once the batch is large + // enough to be worth sending on its own. + if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize) + { + _libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } } } @@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable List<BaseItem> itemsRemoved; lock (_libraryChangedSyncLock) { - // Remove dupes in case some were saved multiple times - foldersAddedTo = _foldersAddedTo - .DistinctBy(x => x.Id) - .ToList(); - - foldersRemovedFrom = _foldersRemovedFrom - .DistinctBy(x => x.Id) - .ToList(); + foldersAddedTo = _foldersAddedTo.Values.ToList(); + foldersRemovedFrom = _foldersRemovedFrom.Values.ToList(); itemsUpdated = _itemsUpdated - .Where(i => !_itemsAdded.Contains(i)) - .DistinctBy(x => x.Id) + .Where(e => !_itemsAdded.ContainsKey(e.Key)) + .Select(e => e.Value) .ToList(); - itemsAdded = _itemsAdded.ToList(); - itemsRemoved = _itemsRemoved.ToList(); + itemsAdded = _itemsAdded.Values.ToList(); + itemsRemoved = _itemsRemoved.Values.ToList(); if (_libraryUpdateTimer is not null) { @@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable _foldersRemovedFrom.Clear(); } + if (itemsAdded.Count == 0 + && itemsUpdated.Count == 0 + && itemsRemoved.Count == 0 + && foldersAddedTo.Count == 0 + && foldersRemovedFrom.Count == 0) + { + return; + } + await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false); } diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index fc174b7c14..b182e5837b 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints public sealed class UserDataChangeNotifier : IHostedService, IDisposable { private const int UpdateDuration = 500; + internal const int MaxBatchSize = 2000; private readonly ISessionManager _sessionManager; private readonly IUserDataManager _userDataManager; private readonly IUserManager _userManager; - private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new(); + private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = []; private readonly Lock _syncLock = new(); private Timer? _updateTimer; + private int _changedItemCount; /// <summary> /// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class. @@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints lock (_syncLock) { - if (_updateTimer is null) + // The window runs from the first change of a batch and is never extended, so a stream + // of changes that never pauses - a library scan - still closes its batches instead of + // holding every item it touched alive until the stream stops. + _updateTimer ??= new Timer( + UpdateTimerCallback, + null, + UpdateDuration, + Timeout.Infinite); + + if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys)) { - _updateTimer = new Timer( - UpdateTimerCallback, - null, - UpdateDuration, - Timeout.Infinite); - } - else - { - _updateTimer.Change(UpdateDuration, Timeout.Infinite); - } - - if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys)) - { - keys = new List<BaseItem>(); + keys = []; _changedItems[e.UserId] = keys; } - keys.Add(e.Item); - var baseItem = e.Item; // Go up one level for indicators if (baseItem is not null) { + Track(keys, baseItem); + var parent = baseItem.GetOwner() ?? baseItem.GetParent(); if (parent is not null) { - keys.Add(parent); + Track(keys, parent); } } + + // A window long enough to cover a burst still has to give way once the batch is + // large enough to be worth sending on its own. + if (_changedItemCount >= MaxBatchSize) + { + _updateTimer.Change(0, Timeout.Infinite); + } + } + } + + private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item) + { + var before = keys.Count; + keys[item.Id] = item; + + if (keys.Count != before) + { + _changedItemCount++; } } private async void UpdateTimerCallback(object? state) { - List<KeyValuePair<Guid, List<BaseItem>>> changes; + List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes; lock (_syncLock) { - // Remove dupes in case some were saved multiple times changes = _changedItems.ToList(); _changedItems.Clear(); + _changedItemCount = 0; if (_updateTimer is not null) { @@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints } } + if (changes.Count == 0) + { + return; + } + foreach (var (userId, changedItems) in changes) { await _sessionManager.SendMessageToUserSessions( [userId], SessionMessageType.UserDataChanged, - () => GetUserDataChangeInfo(userId, changedItems), + () => GetUserDataChangeInfo(userId, changedItems.Values), default).ConfigureAwait(false); } } - private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems) + private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems) { var user = _userManager.GetUserById(userId) ?? throw new ArgumentException("Invalid user ID", nameof(userId)); @@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints { UserId = userId, UserDataList = changedItems - .DistinctBy(x => x.Id) .Select(i => { var dto = _userDataManager.GetUserDataDto(i, user); diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index dc7f972c13..d7319f80f4 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -127,7 +127,7 @@ namespace Emby.Server.Implementations.HttpServer { receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException) + catch (Exception ex) when (IsConnectionGone(ex)) { // ObjectDisposedException/OperationCanceledException: the socket was torn // down underneath us (e.g. by the keep-alive watchdog after the connection @@ -158,7 +158,15 @@ namespace Emby.Server.Implementations.HttpServer if (receiveResult.EndOfMessage) { - await ProcessInternal(pipe.Reader).ConfigureAwait(false); + try + { + await ProcessInternal(pipe.Reader).ConfigureAwait(false); + } + catch (Exception ex) when (IsConnectionGone(ex)) + { + _logger.LogWarning("WS {IP} error sending data: {Message}", RemoteEndPoint, ex.Message); + break; + } } } while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) @@ -170,13 +178,24 @@ namespace Emby.Server.Implementations.HttpServer || _socket.State == WebSocketState.CloseReceived || _socket.State == WebSocketState.CloseSent) { - await _socket.CloseAsync( - WebSocketCloseStatus.NormalClosure, - string.Empty, - cancellationToken).ConfigureAwait(false); + try + { + await _socket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + string.Empty, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsConnectionGone(ex)) + { + // The peer is already gone, there is nobody left to send the close frame to. + _logger.LogDebug("WS {IP} error closing connection: {Message}", RemoteEndPoint, ex.Message); + } } } + private static bool IsConnectionGone(Exception ex) + => ex is WebSocketException or ObjectDisposedException or OperationCanceledException; + private async Task ProcessInternal(PipeReader reader) { ReadResult result = await reader.ReadAsync().ConfigureAwait(false); diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index f634084034..b31cf0f1f5 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -109,6 +109,11 @@ namespace Emby.Server.Implementations.IO lock (_timerLock) { + if (_disposed) + { + return; + } + paths = _affectedPaths.ToList(); } @@ -129,11 +134,12 @@ namespace Emby.Server.Implementations.IO private void ProcessPathChanges(List<string> paths) { - IEnumerable<BaseItem> itemsToRefresh = paths + var itemsToRefresh = paths .Distinct() - .Select(GetAffectedBaseItem) - .Where(item => item is not null) - .DistinctBy(x => x!.Id)!; // Removed null values in the previous .Where() + .Select(TryGetAffectedBaseItem) + .OfType<BaseItem>() + .DistinctBy(x => x.Id) + .ToList(); foreach (var item in itemsToRefresh) { @@ -155,6 +161,19 @@ namespace Emby.Server.Implementations.IO } } + private BaseItem? TryGetAffectedBaseItem(string path) + { + try + { + return GetAffectedBaseItem(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding the item affected by changes to {Path}", path); + return null; + } + } + /// <summary> /// Gets the affected base item. /// </summary> diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 1bf0f8c76c..e51c863f86 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -3,11 +3,13 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,6 +23,7 @@ namespace Emby.Server.Implementations.IO private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; + private readonly IDirectoryService _directoryService; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> @@ -38,6 +41,12 @@ namespace Emby.Server.Implementations.IO /// </summary> private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); + /// <summary> + /// Incremented by every <see cref="Stop"/> so watchers still being created on a background + /// task can tell that the sweep they should have been caught by has already run. + /// </summary> + private int _watcherGeneration; + private bool _disposed; /// <summary> @@ -47,6 +56,7 @@ namespace Emby.Server.Implementations.IO /// <param name="libraryManager">The library manager.</param> /// <param name="configurationManager">The configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> + /// <param name="directoryService">The directory service.</param> /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryMonitor( @@ -54,6 +64,7 @@ namespace Emby.Server.Implementations.IO ILibraryManager libraryManager, IServerConfigurationManager configurationManager, IFileSystem fileSystem, + IDirectoryService directoryService, IHostApplicationLifetime appLifetime, DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { @@ -61,10 +72,11 @@ namespace Emby.Server.Implementations.IO _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; + _directoryService = directoryService; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; appLifetime.ApplicationStarted.Register(Start); - appLifetime.ApplicationStopping.Register(Stop); + appLifetime.ApplicationStopping.Register(Dispose); } /// <inheritdoc /> @@ -115,6 +127,11 @@ namespace Emby.Server.Implementations.IO /// <inheritdoc /> public void Start() { + if (_disposed) + { + return; + } + _libraryManager.ItemAdded += OnLibraryManagerItemAdded; _libraryManager.ItemRemoved += OnLibraryManagerItemRemoved; @@ -228,6 +245,8 @@ namespace Emby.Server.Implementations.IO return; } + var generation = Volatile.Read(ref _watcherGeneration); + // Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel Task.Run(() => { @@ -251,7 +270,11 @@ namespace Emby.Server.Implementations.IO newWatcher.Changed += OnWatcherChanged; newWatcher.Error += OnWatcherError; - if (_fileSystemWatchers.TryAdd(path, newWatcher)) + if (_disposed || Volatile.Read(ref _watcherGeneration) != generation) + { + DisposeWatcher(newWatcher, false); + } + else if (_fileSystemWatchers.TryAdd(path, newWatcher)) { newWatcher.EnableRaisingEvents = true; _logger.LogInformation("Watching directory {Path}", path); @@ -352,6 +375,11 @@ namespace Emby.Server.Implementations.IO { ArgumentException.ThrowIfNullOrEmpty(path); + if (_disposed) + { + return; + } + if (IgnorePatterns.ShouldIgnore(path)) { return; @@ -363,6 +391,8 @@ namespace Emby.Server.Implementations.IO return; } + _directoryService.Invalidate(path); + // Ignore certain files, If the parent of an ignored path has a change event, ignore that too foreach (var i in _tempIgnoredPaths.Keys) { @@ -445,6 +475,8 @@ namespace Emby.Server.Implementations.IO /// </summary> public void Stop() { + Interlocked.Increment(ref _watcherGeneration); + _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; _libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; @@ -489,8 +521,9 @@ namespace Emby.Server.Implementations.IO return; } - Stop(); + // Set before stopping so anything racing us stops handing out new work. _disposed = true; + Stop(); } } } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index ede9b27592..db743c8d31 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -489,11 +489,18 @@ namespace Emby.Server.Implementations.IO ArgumentException.ThrowIfNullOrEmpty(parentPath); ArgumentException.ThrowIfNullOrEmpty(path); - return path.Contains( - Path.TrimEndingDirectorySeparator(parentPath) + Path.DirectorySeparatorChar, - _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + var parent = Path.TrimEndingDirectorySeparator(parentPath); + + // The parent has to be an anchored prefix of the path, otherwise unrelated paths that merely + // contain the parent as a segment (e.g. /media and /data/media/tv) would be treated as related. + return path.Length > parent.Length + && path.StartsWith(parent, _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) + && (Path.EndsInDirectorySeparator(parent) || IsDirectorySeparator(path[parent.Length])); } + private static bool IsDirectorySeparator(char c) + => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + /// <inheritdoc /> public virtual bool AreEqual(string path1, string path2) { diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index b701e7eb6d..7cae2a671b 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -31,6 +31,12 @@ namespace Emby.Server.Implementations.Images var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); var recursive = viewType != CollectionType.playlists; + if (viewType == CollectionType.music) + { + // Music albums usually don't have dedicated backdrops, so use artist instead + includeItemTypes = [BaseItemKind.MusicArtist]; + } + return view.GetItemList(new InternalItemsQuery { CollapseBoxSetItems = false, diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 3691f4e19d..caba304888 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -15,7 +16,6 @@ using Emby.Naming.Common; using Emby.Naming.TV; using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; -using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks.Tasks; using Emby.Server.Implementations.Sorting; @@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; @@ -45,6 +44,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; @@ -74,7 +74,6 @@ namespace Emby.Server.Implementations.Library private readonly Lazy<IProviderManager> _providerManagerFactory; private readonly Lazy<IUserViewManager> _userViewManagerFactory; private readonly IServerApplicationHost _appHost; - private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; private readonly IItemRepository _itemRepository; private readonly IItemPersistenceService _persistenceService; @@ -86,6 +85,8 @@ namespace Emby.Server.Implementations.Library private readonly IPeopleRepository _peopleRepository; private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; + private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; @@ -120,7 +121,6 @@ namespace Emby.Server.Implementations.Library /// <param name="fileSystem">The file system.</param> /// <param name="providerManagerFactory">The provider manager.</param> /// <param name="userViewManagerFactory">The user view manager.</param> - /// <param name="mediaEncoder">The media encoder.</param> /// <param name="itemRepository">The item repository.</param> /// <param name="persistenceService">The item persistence service.</param> /// <param name="nextUpService">The next up service.</param> @@ -132,6 +132,7 @@ namespace Emby.Server.Implementations.Library /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> + /// <param name="localization">The localization manager.</param> /// <param name="mediaStreamRepository">The media stream repository.</param> /// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param> public LibraryManager( @@ -145,7 +146,6 @@ namespace Emby.Server.Implementations.Library IFileSystem fileSystem, Lazy<IProviderManager> providerManagerFactory, Lazy<IUserViewManager> userViewManagerFactory, - IMediaEncoder mediaEncoder, IItemRepository itemRepository, IItemPersistenceService persistenceService, INextUpService nextUpService, @@ -157,6 +157,7 @@ namespace Emby.Server.Implementations.Library IPeopleRepository peopleRepository, IPathManager pathManager, DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + ILocalizationManager localization, IMediaStreamRepository mediaStreamRepository, Lazy<IExternalDataManager> externalDataManagerFactory) { @@ -170,7 +171,6 @@ namespace Emby.Server.Implementations.Library _fileSystem = fileSystem; _providerManagerFactory = providerManagerFactory; _userViewManagerFactory = userViewManagerFactory; - _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; _persistenceService = persistenceService; _nextUpService = nextUpService; @@ -184,6 +184,8 @@ namespace Emby.Server.Implementations.Library _peopleRepository = peopleRepository; _pathManager = pathManager; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; + _localization = localization; + _directoryService = directoryService; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -316,7 +318,7 @@ namespace Emby.Server.Implementations.Library if (wizardChanged) { - _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(); + QueueLibraryScan(); } } @@ -407,6 +409,13 @@ namespace Emby.Server.Implementations.Library } _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); + + // Evict the deleted items from the cache and announce each removal. + foreach (var (item, _, _) in pathMaps) + { + _cache.TryRemove(item.Id, out _); + ReportItemRemoved(item, item.GetOwner() ?? item.GetParent()); + } } public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem) @@ -606,6 +615,12 @@ namespace Emby.Server.Implementations.Library folder.UserData = null; } + // Announce the descendants before the item itself. + foreach (var child in children) + { + ReportItemRemoved(child, item); + } + ReportItemRemoved(item, parent); } @@ -863,7 +878,18 @@ namespace Emby.Server.Implementations.Library wrongTypeItem.GetType().Name, expectedVideoType.Name, path); - DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false }); + + // A full DeleteItem would save the primary version, which resolves its + // alternates again and re-enters here before this row is gone. + DeleteItemsUnsafeFast([wrongTypeItem]); + + // The fast path skips the parent bookkeeping, and the stale item is listed + // under its ParentId, so that folder's cached listing has to be dropped. + if (wrongTypeItem.GetParent() is Folder staleParent) + { + staleParent.Children = null; + staleParent.UserData = null; + } } } @@ -1192,6 +1218,12 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public Guid GetPersonId(string name) + { + return GetItemByNameId<Person>(Person.GetPath(name)); + } + + /// <inheritdoc /> public Person? GetPerson(string name) { var path = Person.GetPath(name); @@ -1204,6 +1236,33 @@ namespace Emby.Server.Implementations.Library return null; } + /// <inheritdoc /> + public Person GetOrCreatePerson(string name) + { + var existing = GetPerson(name); + if (existing is not null) + { + return existing; + } + + var path = Person.GetPath(name); + var info = Directory.CreateDirectory(path); + var item = new Person + { + Name = name, + Id = GetItemByNameId<Person>(path), + DateCreated = info.CreationTimeUtc, + DateModified = info.LastWriteTimeUtc, + Path = path + }; + + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + CreateItem(item, null); + + return item; + } + /// <summary> /// Gets the studio. /// </summary> @@ -1336,15 +1395,6 @@ namespace Emby.Server.Implementations.Library return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId); } - /// <inheritdoc /> - public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - // Ensure the location is available. - Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath); - - return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress); - } - /// <summary> /// Reloads the root media folder. /// </summary> @@ -1471,6 +1521,10 @@ namespace Emby.Server.Implementations.Library var numComplete = 0; var numTasks = tasks.Count; + _logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks); + + var phaseStart = Stopwatch.GetTimestamp(); + foreach (var task in tasks) { // Prevent access to modified closure @@ -1488,20 +1542,45 @@ namespace Emby.Server.Implementations.Library progress.Report(innerPercent); }); - _logger.LogDebug("Running post-scan task {0}", task.GetType().Name); + var taskName = task.GetType().Name; + var taskStart = Stopwatch.GetTimestamp(); + + _logger.LogInformation( + "Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}", + currentNumComplete + 1, + numTasks, + taskName); try { await task.Run(innerProgress, cancellationToken).ConfigureAwait(false); + + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } catch (OperationCanceledException) { - _logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogInformation( + "Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error running post-scan task"); + var elapsed = Stopwatch.GetElapsedTime(taskStart); + _logger.LogError( + ex, + "Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds", + taskName, + Math.Truncate(elapsed.TotalMinutes), + elapsed.Seconds); } numComplete++; @@ -1510,6 +1589,12 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } + var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart); + _logger.LogInformation( + "All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds", + Math.Truncate(phaseElapsed.TotalMinutes), + phaseElapsed.Seconds); + _persistenceService.UpdateInheritedValues(); progress.Report(100); @@ -1727,9 +1812,21 @@ namespace Emby.Server.Implementations.Library return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query); } - public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + /// <inheritdoc/> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user) + { + var query = new InternalItemsQuery(user); + if (user is not null) + { + AddUserToQuery(query, user); + } + + return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query); + } + + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) { - return _countService.GetChildCountBatch(parentIds, userId); + return _countService.GetChildCountBatch(parentIds, user); } /// <inheritdoc/> @@ -1896,14 +1993,14 @@ namespace Emby.Server.Implementations.Library } // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - query.AncestorIds = []; - - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + if (topParentIds.Length == 0) { - query.TopParentIds = [Guid.NewGuid()]; + return; } + + query.TopParentIds = topParentIds; + query.AncestorIds = []; } public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) @@ -1949,12 +2046,15 @@ namespace Emby.Server.Implementations.Library if (parents.All(i => i is ICollectionFolder || i is UserView)) { // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + if (topParentIds.Length > 0) { - query.TopParentIds = [Guid.NewGuid()]; + query.TopParentIds = topParentIds; + } + else + { + SetAncestorIds(query, parents); } } else if (parents.Count == 1 && parents.First() is Folder folder @@ -1963,34 +2063,31 @@ namespace Emby.Server.Implementations.Library { // Playlists and BoxSets store their contents in LinkedChildren and never // populate AncestorIds for those items, so a recursive AncestorIds query - // would return zero rows. Resolve to the linked child IDs up front and - // route through the existing indexed ItemIds filter. - query.ItemIds = folder.LinkedChildren - .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) - .Select(lc => lc.ItemId!.Value) - .ToArray(); - - // Empty linked-children should still return empty rather than scanning everything. - if (query.ItemIds.Length == 0) - { - query.ItemIds = [Guid.NewGuid()]; - } + // would return zero rows. Filter by the descendant set instead, which follows + // the links and keeps descending, so a linked folder contributes what is below + // it as well - the episodes of a Series added to a collection, for example. + query.DescendantOfId = folder.Id; } else { - // We need to be able to query from any arbitrary ancestor up the tree - query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); - - // Prevent searching in all libraries due to empty filter - if (query.AncestorIds.Length == 0) - { - query.AncestorIds = [Guid.NewGuid()]; - } + SetAncestorIds(query, parents); } query.Parent = null; } + private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents) + { + // We need to be able to query from any arbitrary ancestor up the tree + query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); + + // Prevent searching in all libraries due to empty filter + if (query.AncestorIds.Length == 0) + { + query.AncestorIds = [Guid.NewGuid()]; + } + } + private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true) { if (query.User is null) @@ -2230,6 +2327,12 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds); + } + + /// <inheritdoc /> public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType) { _linkedChildrenService.UpsertLinkedChild(parentId, childId, childType); @@ -2315,9 +2418,13 @@ namespace Emby.Server.Implementations.Library { var comparer = Comparers.FirstOrDefault(c => name == c.Type); - // If it requires a user, create a new one, and assign the user if (comparer is IUserBaseItemComparer) { + if (user is null) + { + throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided."); + } + var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances userComparer.User = user; @@ -2367,6 +2474,7 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; // ResolveAlternateVersion only sees the alternate's primary file. // If the alternate is itself a stack (e.g. 1080p part1 + part2), // detect its parts from sibling files so its AdditionalParts persist. @@ -2490,9 +2598,15 @@ namespace Emby.Server.Implementations.Library } } - if (!File.Exists(image.Path)) + if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path)) { - _logger.LogWarning("Image not found at {ImagePath}", image.Path); + _logger.LogWarning( + "{ImageType} image for {ItemName} ({ItemId}) not found at \"{ImagePath}\", source was {SourcePath}", + img.Type, + item.Name, + item.Id, + image.Path, + img.Path); continue; } @@ -2552,6 +2666,8 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } + ForgetDroppedLocalAlternateVersions(items); + // Resolve and add any local alternate version items that don't exist yet // This ensures they exist in the database when LinkedChildren are processed var allItems = new List<BaseItem>(items); @@ -2580,6 +2696,7 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + altVideo.IsInMixedFolder = video.IsInMixedFolder; // ResolveAlternateVersion only sees the alternate's primary file. // If the alternate is itself a stack (e.g. 1080p part1 + part2), // detect its parts from sibling files so its AdditionalParts persist. @@ -2640,6 +2757,30 @@ namespace Emby.Server.Implementations.Library public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync([item], parent, updateReason, cancellationToken); + /// <summary> + /// Forgets the cached local alternate versions of the supplied items that they no longer list. + /// </summary> + /// <param name="items">The items about to be saved.</param> + private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items) + { + foreach (var video in items.OfType<Video>()) + { + var videoType = video.GetType(); + var keptIds = video.LocalAlternateVersions + .Where(path => !string.IsNullOrEmpty(path)) + .Select(path => GetNewItemId(path, videoType)) + .ToHashSet(); + + foreach (var versionId in GetLocalAlternateVersionIds(video)) + { + if (!keptIds.Contains(versionId)) + { + _cache.TryRemove(versionId, out _); + } + } + } + } + /// <inheritdoc /> public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken) { @@ -2863,7 +3004,8 @@ namespace Emby.Server.Implementations.Library "views", _fileSystem.GetValidFilename(viewType.ToString())); - var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView)); + // The display name is localized, so it must not take part in the id. + var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); var item = GetItemById(id) as UserView; @@ -2887,6 +3029,13 @@ namespace Emby.Server.Implementations.Library refresh = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.ForcedSortName = sortName; + + refresh = true; + } if (refresh) { @@ -2907,7 +3056,9 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + + // The name is either localized (grouped views) or the library folder's own name. + var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); var id = GetNewItemId(idValues, typeof(UserView)); @@ -2937,6 +3088,11 @@ namespace Emby.Server.Implementations.Library isNew = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } var lastRefreshedUtc = item.DateLastRefreshed; var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval; @@ -3038,7 +3194,7 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); if (!string.IsNullOrEmpty(uniqueId)) { idValues += uniqueId; @@ -3072,9 +3228,10 @@ namespace Emby.Server.Implementations.Library isNew = true; } - if (viewType != item.ViewType) + if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal)) { item.ViewType = viewType; + item.Name = name; item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); } @@ -3195,11 +3352,11 @@ namespace Emby.Server.Implementations.Library } } - if (!episode.ProductionYear.HasValue) + if (episode.ProductionYear is null) { episode.ProductionYear = episodeInfo.Year; - if (episode.ProductionYear.HasValue) + if (episode.ProductionYear is not null) { changed = true; } @@ -3276,9 +3433,11 @@ namespace Emby.Server.Implementations.Library var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) { - yield break; + return []; } + var candidates = new List<ExtraCandidate>(); + var count = filtered.Count; for (var i = 0; i < count; i++) { @@ -3292,35 +3451,50 @@ namespace Emby.Server.Implementations.Library foreach (var file in filesInSubFolderList) { - if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType)) + if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { continue; } - var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder); - if (extra is not null) - { - yield return extra; - } + AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder); } } - else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType)) + else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { - var extra = GetExtra(current, extraType.Value, false); - if (extra is not null) - { - yield return extra; - } + AddCandidate(current, extraType.Value, extraRule, false); } } - BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder) + var extras = new List<BaseItem>(); + var typeCounters = new Dictionary<ExtraType, int>(); + + // Order by path so that the numbering handed out below does not depend on the + // order the file system happened to list the folder in + foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal)) + { + var extra = PrepareExtra(candidate); + if (extra is not null) + { + extras.Add(extra); + } + } + + return extras; + + void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder) { var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType)); - if (extra is not Video && extra is not Audio) + if (extra is Video or Audio) { - return null; + candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder)); } + } + + BaseItem? PrepareExtra(ExtraCandidate candidate) + { + var resolved = candidate.Extra; + var extra = resolved; + var name = GetExtraName(candidate, ownerVideoInfo, typeCounters); // Try to retrieve it from the db. If we don't find it, use the resolved version var itemById = GetItemById(extra.Id); @@ -3329,10 +3503,18 @@ namespace Emby.Server.Implementations.Library extra = itemById; } + // An extra is named after its file, so the file is the source of truth. Items created + // by older versions, or renamed by a metadata provider, are corrected here; + // RefreshExtras persists the change. + if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true) + { + extra.Name = name; + } + // Only update extra type if it is more specific then the currently known extra type - if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown) + if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown) { - extra.ExtraType = extraType; + extra.ExtraType = candidate.ExtraType; } // Only return items that are actual extras (have ExtraType set) @@ -3340,7 +3522,7 @@ namespace Emby.Server.Implementations.Library // so that RefreshExtras can detect when they need updating and set ForceSave. if (extra.ExtraType is not null) { - extra.IsInMixedFolder = isInMixedFolder; + extra.IsInMixedFolder = candidate.IsInMixedFolder; return extra; } @@ -3348,6 +3530,57 @@ namespace Emby.Server.Implementations.Library } } + /// <summary> + /// Gets the name to give an extra. + /// </summary> + /// <param name="candidate">The resolved extra.</param> + /// <param name="ownerVideoInfo">The naming info of the owner.</param> + /// <param name="typeCounters">Number of extras named after their type so far, per type.</param> + /// <returns>The name.</returns> + private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters) + { + var isNamedAfterOwner = candidate.ExtraRule.RuleType switch + { + ExtraRuleType.Filename => true, + ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase), + _ => false + }; + + if (!isNamedAfterOwner) + { + return candidate.Extra.Name; + } + + typeCounters.TryGetValue(candidate.ExtraType, out var seen); + typeCounters[candidate.ExtraType] = seen + 1; + + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + + return seen == 0 + ? typeName + : string.Format( + CultureInfo.InvariantCulture, + _localization.GetServerLocalizedString("NameExtraNumbered"), + typeName, + seen + 1); + } + + private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch + { + ExtraType.Clip => "NameExtraClip", + ExtraType.Trailer => "NameExtraTrailer", + ExtraType.BehindTheScenes => "NameExtraBehindTheScenes", + ExtraType.DeletedScene => "NameExtraDeletedScene", + ExtraType.Interview => "NameExtraInterview", + ExtraType.Scene => "NameExtraScene", + ExtraType.Sample => "NameExtraSample", + ExtraType.ThemeSong => "NameExtraThemeSong", + ExtraType.ThemeVideo => "NameExtraThemeVideo", + ExtraType.Featurette => "NameExtraFeaturette", + ExtraType.Short => "NameExtraShort", + _ => "NameExtraUnknown" + }; + public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem) { foreach (var map in _configurationManager.Configuration.PathSubstitutions) @@ -3419,11 +3652,23 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc/> + public int DeleteOrphanedCredits() + { + return _peopleRepository.DeleteOrphanedCredits(); + } + + /// <inheritdoc/> public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes) { return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes); } + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds) + { + return _peopleRepository.GetPeopleByItems(itemIds); + } + public void UpdatePeople(BaseItem item, List<PersonInfo> people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); @@ -3457,7 +3702,20 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return item.GetImageInfo(image.Type, imageIndex); + var localImage = item.GetImageInfo(image.Type, imageIndex); + if (localImage is null) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Downloaded {0} image {1} from {2} is not attached to {3} ({4})", + image.Type, + imageIndex, + url, + item.Name, + item.Id)); + } + + return localImage; } catch (HttpRequestException ex) { @@ -3479,7 +3737,13 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); } - throw new InvalidOperationException("Unable to convert any images to local"); + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Unable to convert any {0} image url in \"{1}\" to a local file for {2} ({3})", + image.Type, + image.Path, + item.Name, + item.Id)); } public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) @@ -3535,6 +3799,10 @@ namespace Emby.Server.Implementations.Library AddMediaPathInternal(name, path, false); } } + + // The libraries root was listed before this folder existed, so drop that listing: + // anything still reading it resolves the library set without the new folder. + _directoryService.Invalidate(virtualFolderPath); } finally { @@ -3542,7 +3810,7 @@ namespace Emby.Server.Implementations.Library if (refreshLibrary) { - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3561,27 +3829,14 @@ namespace Emby.Server.Implementations.Library var itemUpdateType = ItemUpdateType.MetadataDownload; var saveEntity = false; - var createEntity = false; var personEntity = GetPerson(person.Name); if (personEntity is null) { try { - var path = Person.GetPath(person.Name); - var info = Directory.CreateDirectory(path); - personEntity = new Person() - { - Name = person.Name, - Id = GetItemByNameId<Person>(path), - DateCreated = info.CreationTimeUtc, - DateModified = info.LastWriteTimeUtc, - Path = path - }; - - personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); + personEntity = GetOrCreatePerson(person.Name); saveEntity = true; - createEntity = true; } catch (Exception ex) { @@ -3615,11 +3870,6 @@ namespace Emby.Server.Implementations.Library if (saveEntity) { - if (createEntity) - { - CreateItems([personEntity], null, CancellationToken.None); - } - await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); personEntity.DateLastSaved = DateTime.UtcNow; @@ -3628,13 +3878,16 @@ namespace Emby.Server.Implementations.Library } } - private void StartScanInBackground() + internal Task StartScanInBackground() { - Task.Run(() => + // An active scan already handles library structure changes, so this request can be dropped. + if (IsScanRunning) { - // No need to start if scanning the library because it will handle it - ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); - }); + return Task.CompletedTask; + } + + // Queue instead of restarting so a scan that starts after the check is allowed to finish. + return Task.Run(QueueLibraryScan); } public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath) @@ -3659,7 +3912,9 @@ namespace Emby.Server.Implementations.Library } var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName) + ?? throw new FileNotFoundException( + string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); CreateShortcut(virtualFolderPath, pathInfo); @@ -3680,7 +3935,9 @@ namespace Emby.Server.Implementations.Library ArgumentNullException.ThrowIfNull(mediaPath); var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName) + ?? throw new FileNotFoundException( + string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -3719,9 +3976,9 @@ namespace Emby.Server.Implementations.Library var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var path = Path.Combine(rootFolderPath, name); + var path = FileSystemHelper.GetChildPath(rootFolderPath, name); - if (!Directory.Exists(path)) + if (path is null || !Directory.Exists(path)) { throw new FileNotFoundException("The media folder does not exist"); } @@ -3731,6 +3988,7 @@ namespace Emby.Server.Implementations.Library try { Directory.Delete(path, true); + _directoryService.Invalidate(path); } finally { @@ -3740,7 +3998,7 @@ namespace Emby.Server.Implementations.Library { await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); - StartScanInBackground(); + _ = StartScanInBackground(); } else { @@ -3785,9 +4043,9 @@ namespace Emby.Server.Implementations.Library ArgumentException.ThrowIfNullOrEmpty(mediaPath); var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; - var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); + var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName); - if (!Directory.Exists(virtualFolderPath)) + if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath)) { throw new FileNotFoundException( string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); @@ -3800,6 +4058,7 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(shortcut)) { _fileSystem.DeleteFile(shortcut); + _directoryService.Invalidate(shortcut); } var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -3843,6 +4102,7 @@ namespace Emby.Server.Implementations.Library } _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); + _directoryService.Invalidate(lnk); RemoveContentTypeOverrides(path); } @@ -3882,9 +4142,35 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> + public IReadOnlyList<string> GetTagNames(InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetTagNames(query); + } + + /// <inheritdoc /> public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) { return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType); + } + + private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder); } } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 97e00177b6..e9bba05839 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -384,7 +384,13 @@ namespace Emby.Server.Implementations.Library { ArgumentNullException.ThrowIfNull(item); - var hasMediaSources = (IHasMediaSources)item; + // Clients can ask for the sources of an item that has none (a container queued by mistake). + if (item is not IHasMediaSources hasMediaSources) + { + throw new ArgumentException( + string.Format(CultureInfo.InvariantCulture, "{0} {1} has no media sources and cannot be played.", item.GetType().Name, item.Id), + nameof(item)); + } var sources = hasMediaSources.GetMediaSources(enablePathSubstitution); @@ -494,7 +500,12 @@ namespace Emby.Server.Implementations.Library { var index = userData.SubtitleStreamIndex.Value; // Make sure the saved index is still valid - if (index == -1 || source.MediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.Index == index)) + var savedStream = source.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + // "Only forced" rules out full tracks entirely, so a remembered one must not resurrect them. + // The client reports whatever is playing, so an index remembered under another mode sticks forever otherwise. + if (index == -1 + || (savedStream is not null + && (user.SubtitleMode != SubtitlePlaybackMode.OnlyForced || savedStream.IsForced))) { source.DefaultSubtitleStreamIndex = index; return; diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 7591359ea4..7d0f3900c5 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("String can't be empty.", nameof(attribute)); } - var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc. + // The code below only supports aliases for attributes in the form of "<alias>id". + ReadOnlySpan<char> shortAttr = attribute switch + { + _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb", + _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb", + _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb", + _ => ReadOnlySpan<char>.Empty + }; - // Must be at least 3 characters after the attribute =, ], any character, - // then we offset it by 1, because we want the index and not length. - var maxIndex = str.Length - attribute.Length - 2; - while (attributeIndex > -1 && attributeIndex < maxIndex) + for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;) { - var attributeEnd = attributeIndex + attribute.Length; + // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'. + var subStr = str[strIndex..]; + int attributeEnd = 0; + + if (shortAttr.Length > 0) + { + // If we are using an alias it should be shorter (and a prefix), so let's search for that. + attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + shortAttr.Length; + } + else + { + attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + attribute.Length; + } + + // The next iteration should start at the end of the attribute we just found. + // If attributeIndex < 0, the loop will end and strIndex won't be used again. + strIndex += attributeEnd; + if (attributeIndex > 0) { - var attributeOpener = str[attributeIndex - 1]; + var attributeOpener = subStr[attributeIndex - 1]; var attributeCloser = attributeOpener switch { '[' => ']', @@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library '{' => '}', _ => '\0' }; - if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-')) + + if (attributeCloser != '\0') { - var closingIndex = str[attributeEnd..].IndexOf(attributeCloser); + if (shortAttr.Length > 0 + && attributeEnd + 1 < subStr.Length + && (subStr[attributeEnd] is 'i' or 'I') + && (subStr[attributeEnd + 1] is 'd' or 'D')) + { + // We were searching for a shortened attribute, but it's followed by "id" - let's skip it. + attributeEnd += 2; + } - // Must be at least 1 character before the closing bracket. - if (closingIndex > 1) + // attributeEnd points at '='. + // We need at least 1 more character and the closing bracket after that. + if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-')) { - return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser); + + // Must be at least 1 character before the closing bracket. + if (closingIndex > 1) + { + var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim(); + + if (trimmed.Length > 0) + { + return trimmed.ToString(); + } + } } } } - - str = str[attributeEnd..]; - attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); } // for imdbid we also accept pattern matching @@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library return match ? imdbId.ToString() : null; } - // Allow tmdb as an alias for tmdbid - if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase)) - { - var tmdbValue = str.GetAttributeValue("tmdb"); - if (tmdbValue is not null) - { - return tmdbValue; - } - } - return null; } diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs index fad948ad97..2a50fcc7fe 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -43,7 +44,19 @@ public class PathManager : IPathManager public string? GetAttachmentPath(string mediaSourceId, string fileName) { var folder = GetAttachmentFolderPath(mediaSourceId); - return folder is null ? null : Path.Combine(folder, fileName); + if (folder is null) + { + return null; + } + + var safeName = PathHelper.GetSafeLeafFileName(fileName); + if (safeName is null) + { + _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId); + return null; + } + + return Path.Combine(folder, safeName); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index b9f9f29723..a0f75e4ddb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers : base(logger, namingOptions, directoryService) { _namingOptions = namingOptions; - _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) }; - _videoResolvers = new IItemResolver[] { this }; + _trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)]; + _videoResolvers = [this]; } protected override Video Resolve(ItemResolveArgs args) @@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers _ => _videoResolvers }; - public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "") + public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "") { var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot); - if (extraResult.ExtraType is null) + if (extraResult.ExtraType is null || extraResult.Rule is null) { extraType = null; + extraRule = null; return false; } @@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } extraType = extraResult.ExtraType; + extraRule = extraResult.Rule; return isValid; } diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index ba320266a4..b3bdea704a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs @@ -2,6 +2,7 @@ using Emby.Naming.Common; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers public class GenericVideoResolver<T> : BaseVideoResolver<T> where T : Video, new() { + private readonly bool _parseName; + /// <summary> /// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> - public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) + /// <param name="parseName">Whether to parse the file name for metadata such as the year.</param> + public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false) : base(logger, namingOptions, directoryService) { + _parseName = parseName; + } + + /// <inheritdoc /> + protected override T Resolve(ItemResolveArgs args) + { + return ResolveVideo<T>(args, _parseName); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 68b66ab7f5..80375ae12d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // We need to only look at the name of this actual item (not parents) var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan()); - var tmdbid = justName.GetAttributeValue("tmdbid"); + // The fallback filename is only used when the item isn't in a mixed folder + var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan()); - // If not in a mixed folder and ID not found in folder path, check filename - if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder) + item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid")); + item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid")); + + string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute) { - tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid"); - } + var id = name.GetAttributeValue(attribute); + + // If not in a mixed folder and ID not found in folder path, check filename + if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder) + { + id = fallbackName.GetAttributeValue(attribute); + } - item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid); + return id; + } if (!string.IsNullOrEmpty(item.Path)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 74c1f69616..d6513fc79c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; @@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers }; } + // Anything directly inside the internal playlists folder is a playlist, even when its + // playlist.xml is missing: failing to resolve here makes the library scan treat the + // playlist as deleted from disk and remove it, taking its items with it. + if (args.Parent is PlaylistsFolder) + { + return new Playlist + { + Path = args.Path, + Name = filename, + OpenAccess = true + }; + } + // It's a directory-based playlist if the directory contains a playlist file IEnumerable<string> filePaths; try diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 6e9a38fd34..a8bd832cc8 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV args.LibraryOptions.SeasonZeroDisplayName : string.Format( CultureInfo.InvariantCulture, - _localization.GetLocalizedString("NameSeasonNumber"), + _localization.GetServerLocalizedString("NameSeasonNumber"), seasonNumber, args.LibraryOptions.PreferredMetadataLanguage); } @@ -129,6 +129,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var tmdbId = justName.GetAttributeValue("tmdbid"); item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + + // Anime databases model a single cour as its own entry, so a multi-season + // series maps to one of these ids per season rather than one per series. + var anidbId = justName.GetAttributeValue("anidbid"); + item.TrySetProviderId("AniDB", anidbId); + + var aniListId = justName.GetAttributeValue("anilistid"); + item.TrySetProviderId("AniList", aniListId); + + var aniSearchId = justName.GetAttributeValue("anisearchid"); + item.TrySetProviderId("AniSearch", aniSearchId); } } } diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs index a5be3f07bd..a8ee416b31 100644 --- a/Emby.Server.Implementations/Library/Search/SearchManager.cs +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -92,49 +92,45 @@ public class SearchManager : ISearchManager await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false); var externalResults = await externalTask.ConfigureAwait(false); - var fromExternal = externalResults.Count > 0; - IReadOnlyList<SearchResult> results; - if (fromExternal) - { - results = externalResults; - } - else - { - results = await internalTask.ConfigureAwait(false); - if (_internalProviders.Length > 0) - { - _logger.LogDebug("No results from external providers, using internal provider results"); - } - } // Internal providers apply user-access filtering inline in their queries. External // providers don't know about user permissions, so they may return IDs from hidden - // libraries or items the user is otherwise blocked from. Run the post-filter only - // when results came from externals to close that gap. The Items controller's second - // roundtrip via folder.GetItems applies most of these again, but it does not restrict - // by TopParentIds when ItemIds is set. - if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) + // libraries or items the user is otherwise blocked from. Filter them here to close + // that gap. The Items controller's second roundtrip via folder.GetItems applies most + // of these again, but it does not restrict by TopParentIds when ItemIds is set. + if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) { var user = _userManager.GetUserById(query.UserId.Value); if (user is not null) { - results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false); + externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false); } } - return results; + if (externalResults.Count > 0) + { + return externalResults; + } + + if (_internalProviders.Length > 0) + { + _logger.LogDebug("No results from external providers, using internal provider results"); + } + + return await internalTask.ConfigureAwait(false); } private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync( IReadOnlyList<SearchResult> candidates, User user, + SearchProviderQuery query, CancellationToken cancellationToken) { - // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates - // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds - // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty. - var accessFilter = new InternalItemsQuery(user); - _libraryManager.ConfigureUserAccess(accessFilter, user); + // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds + // for the user's accessible libraries. The candidate ids are applied to the query below + // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when + // ItemIds is non-empty. + var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager); Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)]; @@ -147,17 +143,24 @@ public class SearchManager : ISearchManager baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); - var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false); - if (allowedCount == candidates.Count) + var allowed = await baseQuery + .Select(e => new { e.Id, e.PrimaryVersionId }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var allowedIds = allowed.Select(e => e.Id).ToHashSet(); + + // A provider can return both an alternate version and the primary it belongs to, and the + // two are one item to the user. + allowedIds.ExceptWith(allowed + .Where(e => e.PrimaryVersionId.HasValue && allowedIds.Contains(e.PrimaryVersionId.Value)) + .Select(e => e.Id)); + + if (allowedIds.Count == candidates.Count) { return candidates; } - var allowedIds = await baseQuery - .Select(e => e.Id) - .ToHashSetAsync(cancellationToken) - .ConfigureAwait(false); - var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList(); if (filtered.Count < candidates.Count) { diff --git a/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs new file mode 100644 index 0000000000..6e3f01de13 --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchQueryAccessFilter.cs @@ -0,0 +1,38 @@ +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Builds the access filter that decides which items a search may return for a user. +/// </summary> +internal static class SearchQueryAccessFilter +{ + /// <summary> + /// Builds an access filter carrying the search's library access and type filters. + /// </summary> + /// <param name="user">The user the search runs for.</param> + /// <param name="query">The search query.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The access filter.</returns> + public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager) + { + // The type filters have to travel with the access filter: a by-name item belongs to no + // library, so it carries no TopParentId to match, and the library filter only knows to + // exempt it when the query says those types are wanted. A search scoped to a parent gets + // no exemption because a by-name item has no parent to descend from either. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = query.IncludeItemTypes, + ExcludeItemTypes = query.ExcludeItemTypes, + IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty() + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs index bc766f1c8c..2cbfb6a4fa 100644 --- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -114,7 +114,8 @@ public class SqlSearchProvider : IInternalSearchProvider dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); dbQuery = ApplyParentFilter(dbQuery, query.ParentId); - dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId); + dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query); + dbQuery = ExcludeVersionsOfMatchedPrimaries(dbQuery); // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it @@ -193,11 +194,18 @@ public class SqlSearchProvider : IInternalSearchProvider return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); } + private static IQueryable<BaseItemEntity> ExcludeVersionsOfMatchedPrimaries(IQueryable<BaseItemEntity> query) + { + var matched = query; + return query.Where(e => e.PrimaryVersionId == null || !matched.Any(p => p.Id == e.PrimaryVersionId)); + } + private IQueryable<BaseItemEntity> ApplyUserAccessFilter( JellyfinDbContext dbContext, IQueryable<BaseItemEntity> query, - Guid? userId) + SearchProviderQuery searchQuery) { + var userId = searchQuery.UserId; if (!userId.HasValue || userId.Value.IsEmpty()) { return query; @@ -209,8 +217,7 @@ public class SqlSearchProvider : IInternalSearchProvider return query; } - var accessFilter = new InternalItemsQuery(user); - _libraryManager.ConfigureUserAccess(accessFilter, user); + var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager); return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter); } diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 57d1f7c770..cc8f0fd24e 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,3 +1,5 @@ +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees. + using System; using System.Collections.Generic; using System.Linq; @@ -172,7 +174,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie var allCandidateIdsList = allCandidateIds.ToList(); var accessibleItems = await baseQuery .WhereOneOrMany(allCandidateIdsList, e => e.Id) - .Select(e => new { e.Id, e.PresentationUniqueKey }) + .Select(e => new { e.Id, e.PresentationUniqueKey, e.PrimaryVersionId }) .ToListAsync(cancellationToken).ConfigureAwait(false); // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey @@ -189,6 +191,9 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie var orderedIds = accessibleItems .Where(x => scores.ContainsKey(x.Id)) .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + // Two versions of one movie score the same, so name the primary as the + // representative of the group rather than whichever came back first. + .ThenBy(x => x.PrimaryVersionId.HasValue) .DistinctBy(x => x.PresentationUniqueKey) .Take(limit) .Select(x => x.Id) @@ -245,6 +250,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie result[id] = []; } + var hiddenVersionIds = context.BaseItems.AsNoTracking() + .Where(e => e.PrimaryVersionId != null + && context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId)) + .Select(e => e.Id); + foreach (var (valueType, weight) in _itemValueDimensions) { var sourceRows = await context.ItemValuesMap.AsNoTracking() @@ -260,7 +270,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie } var candidateRows = await context.ItemValuesMap.AsNoTracking() - .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Where(m => !hiddenVersionIds.Contains(m.ItemId) && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) .ToListAsync(cancellationToken).ConfigureAwait(false); @@ -276,6 +286,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie if (personSourceRows.Count > 0) { var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => !hiddenVersionIds.Contains(m.ItemId)) .Where(m => context.PeopleBaseItemMap .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) .Select(s => s.PeopleId) diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs new file mode 100644 index 0000000000..75aea0eab6 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsAccessFilter.cs @@ -0,0 +1,42 @@ +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Builds the access filter that decides which items a similar-items lookup may return for a user. +/// </summary> +internal static class SimilarItemsAccessFilter +{ + private static readonly BaseItemKind[] _itemByNameKinds = + [ + BaseItemKind.Person, + BaseItemKind.Genre, + BaseItemKind.MusicGenre, + BaseItemKind.MusicArtist, + BaseItemKind.Studio + ]; + + /// <summary> + /// Builds an access filter carrying the user's library access and parental restrictions. + /// </summary> + /// <param name="user">The user the lookup runs for.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The access filter.</returns> + public static InternalItemsQuery Build(User user, ILibraryManager libraryManager) + { + // IncludeItemTypes is read only for the by-name exemption here; the caller applies this + // filter through ApplyAccessFiltering, which does not translate it into a type restriction. + var accessFilter = new InternalItemsQuery(user) + { + IncludeItemTypes = _itemByNameKinds + }; + + // ConfigureUserAccess populates TopParentIds for the libraries the user may open. + libraryManager.ConfigureUserAccess(accessFilter, user); + + return accessFilter; + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index d923cff07e..a18a17b593 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -7,8 +7,10 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -16,11 +18,13 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.SimilarItems; @@ -35,6 +39,8 @@ public class SimilarItemsManager : ISimilarItemsManager private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; private ISimilarItemsProvider[] _similarItemsProviders = []; /// <summary> @@ -45,18 +51,24 @@ public class SimilarItemsManager : ISimilarItemsManager /// <param name="libraryManager">The library manager.</param> /// <param name="fileSystem">The file system.</param> /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="queryHelpers">The shared item query helpers.</param> public SimilarItemsManager( ILogger<SimilarItemsManager> logger, IServerApplicationPaths appPaths, ILibraryManager libraryManager, IFileSystem fileSystem, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers) { _logger = logger; _appPaths = appPaths; _libraryManager = libraryManager; _fileSystem = fileSystem; _serverConfigurationManager = serverConfigurationManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; } /// <inheritdoc/> @@ -183,6 +195,7 @@ public class SimilarItemsManager : ISimilarItemsManager // Collect references in batches and resolve against local library. // Stop fetching once we have enough resolved local items. const int BatchSize = 20; + const int MaxRemoteReferenceFetchLimit = 500; var remaining = requestedLimit - allResults.Count; var collectedReferences = new List<SimilarItemReference>(); var pendingBatch = new List<SimilarItemReference>(); @@ -199,7 +212,7 @@ public class SimilarItemsManager : ISimilarItemsManager remaining -= resolvedItems.Count; pendingBatch.Clear(); - if (remaining <= 0) + if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit) { break; } @@ -229,11 +242,64 @@ public class SimilarItemsManager : ISimilarItemsManager } } - return allResults + var ordered = allResults .OrderByDescending(x => x.Score) .Select(x => x.Item) .Take(requestedLimit) .ToList(); + + return await FilterByLibraryAccessAsync(ordered, user, cancellationToken).ConfigureAwait(false); + } + + private async Task<IReadOnlyList<BaseItem>> FilterByLibraryAccessAsync( + IReadOnlyList<BaseItem> candidates, + User? user, + CancellationToken cancellationToken) + { + if (candidates.Count == 0 || user is null) + { + return candidates; + } + + var accessFilter = SimilarItemsAccessFilter.Build(user, _libraryManager); + + // No accessible libraries means nothing to compare against, and an empty TopParentIds set + // would disable the filter rather than reject everything. + if (accessFilter.TopParentIds.Length == 0) + { + return candidates; + } + + Guid[] candidateIds = [.. candidates.Select(c => c.Id)]; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(candidateIds, e => e.Id); + + baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); + + var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false); + if (allowedCount == candidates.Count) + { + return candidates; + } + + var allowedIds = await baseQuery + .Select(e => e.Id) + .ToHashSetAsync(cancellationToken) + .ConfigureAwait(false); + + var filtered = candidates.Where(c => allowedIds.Contains(c.Id)).ToList(); + _logger.LogDebug( + "Dropped {Dropped} of {Total} similar-item candidates due to user access filtering", + candidates.Count - filtered.Count, + candidates.Count); + + return filtered; + } } /// <inheritdoc/> @@ -375,19 +441,39 @@ public class SimilarItemsManager : ISimilarItemsManager var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false); + // Filter once across every category rather than per baseline, so a batch provider costs one + // access query no matter how many categories it produced. + var allItems = batchResults.Values.SelectMany(items => items).DistinctBy(item => item.Id).ToList(); + var allowed = await FilterByLibraryAccessAsync(allItems, query.User, cancellationToken).ConfigureAwait(false); + + HashSet<Guid>? allowedIds = allowed.Count == allItems.Count + ? null + : [.. allowed.Select(item => item.Id)]; + var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count); foreach (var baseline in baselineItems) { - if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0) + if (!batchResults.TryGetValue(baseline.Id, out var similar) || similar.Count == 0) + { + continue; + } + + if (allowedIds is not null) { - recommendations.Add(new SimilarItemsRecommendation + similar = similar.Where(item => allowedIds.Contains(item.Id)).ToList(); + if (similar.Count == 0) { - BaselineItemName = baseline.Name, - CategoryId = baseline.Id, - RecommendationType = recommendationType, - Items = similar - }); + continue; + } } + + recommendations.Add(new SimilarItemsRecommendation + { + BaselineItemName = baseline.Name, + CategoryId = baseline.Id, + RecommendationType = recommendationType, + Items = similar + }); } return recommendations; @@ -565,7 +651,13 @@ public class SimilarItemsManager : ISimilarItemsManager try { - var stream = File.OpenRead(cachePath); + var stream = new FileStream( + cachePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); await using (stream.ConfigureAwait(false)) { var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); @@ -589,6 +681,7 @@ public class SimilarItemsManager : ISimilarItemsManager private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken) { + string? tempPath = null; try { var directory = Path.GetDirectoryName(cachePath); @@ -603,16 +696,34 @@ public class SimilarItemsManager : ISimilarItemsManager ExpiresAt = DateTime.UtcNow.Add(cacheDuration) }; - var stream = File.Create(cachePath); + tempPath = cachePath + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp"; + var stream = File.Create(tempPath); await using (stream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); } + + File.Move(tempPath, cachePath, true); + tempPath = null; } catch (IOException ex) { _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); } + finally + { + if (tempPath is not null) + { + try + { + File.Delete(tempPath); + } + catch (IOException ex) + { + _logger.LogDebug(ex, "Failed to delete temporary similar items cache file {TempPath}", tempPath); + } + } + } } private sealed class SimilarItemsCache diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 40cd2bb69c..0680046c11 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library } else { - var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault(); + var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + var userData = userDataRow is not null ? Map(userDataRow) : null; if (userData is not null) { result[item.Id] = userData; @@ -211,37 +212,32 @@ namespace Emby.Server.Implementations.Library return result; } - // Build a single query for all missing items + // Build a single query for all missing items. Fetch rows by item alone so rows kept + // under keys from older metadata resolve the same way as the in-memory path. var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList(); - var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList(); - if (allKeys.Count > 0) - { - using var context = _repository.CreateDbContext(); - var userDataArray = context.UserData - .AsNoTracking() - .Where(e => e.UserId.Equals(user.Id)) - .WhereOneOrMany(allItemIds, e => e.ItemId) - .WhereOneOrMany(allKeys, e => e.CustomDataKey) - .ToArray(); - - var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); - foreach (var (item, keys) in itemsNeedingQuery) + using var context = _repository.CreateDbContext(); + var userDataArray = context.UserData + .AsNoTracking() + .Where(e => e.UserId.Equals(user.Id)) + .WhereOneOrMany(allItemIds, e => e.ItemId) + .ToArray(); + + var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); + foreach (var (item, keys) in itemsNeedingQuery) + { + UserItemData userData; + if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) { - UserItemData userData; - if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) - { - var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N")); - userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First()); - } - else - { - userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; - } - - result[item.Id] = userData; - var cacheKey = GetCacheKey(user.InternalId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); + userData = Map(ResolveUserDataRow(item, itemUserData)!); } + else + { + userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; + } + + result[item.Id] = userData; + var cacheKey = GetCacheKey(user.InternalId, item.Id); + _cache.AddOrUpdate(cacheKey, userData); } return result; @@ -291,8 +287,8 @@ namespace Emby.Server.Implementations.Library { using var dbContext = _repository.CreateDbContext(); withLocalAlternates = dbContext.LinkedChildren - .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion - && localProbeIds.Contains(lc.ParentId)) + .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion) + .WhereOneOrMany(localProbeIds, lc => lc.ParentId) .Select(lc => lc.ParentId) .Distinct() .ToHashSet(); @@ -356,12 +352,41 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public UserItemData? GetUserData(User user, BaseItem item) { - return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() + ArgumentNullException.ThrowIfNull(user); + var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + return row is not null ? Map(row) : new UserItemData() { Key = item.GetUserDataKeys()[0], }; } + /// <summary> + /// Picks the row matching the item's current user data keys, in key order, so rows left behind + /// under keys from older metadata don't take priority over the rows the write path updates. + /// </summary> + /// <param name="item">The item whose keys to match.</param> + /// <param name="rows">The candidate user data rows for a single user.</param> + /// <returns>The best matching row, or <c>null</c> when there are none.</returns> + private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows) + { + var candidates = rows?.ToList(); + if (candidates is null || candidates.Count == 0) + { + return null; + } + + foreach (var key in item.GetUserDataKeys()) + { + var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + } + + return candidates[0]; + } + /// <inheritdoc /> public UserItemDataDto? GetUserDataDto(BaseItem item, User user) => GetUserDataDto(item, null, user, new DtoOptions()); diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 9512b0ffd7..cfb2dd53d3 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,17 +60,10 @@ namespace Emby.Server.Implementations.Library var folderViewType = collectionFolder?.CollectionType; // Playlist and BoxSet libraries require special handling because the folder only references linked items - if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) + if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) + && !HasVisibleChild(folder, user)) { - var items = folder.GetItemList(new InternalItemsQuery(user) - { - ParentId = folder.ParentId - }); - - if (!items.Any(item => item.IsVisible(user))) - { - continue; - } + continue; } if (UserView.IsUserSpecific(folder)) @@ -112,7 +105,7 @@ namespace Emby.Server.Implementations.Library if (_config.Configuration.EnableFolderView) { - var name = _localizationManager.GetLocalizedString("Folders"); + var name = _localizationManager.GetServerLocalizedString("Folders"); list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty)); } @@ -127,7 +120,7 @@ namespace Emby.Server.Implementations.Library list.AddRange(channels); - if (_liveTvManager.GetEnabledUsers().Select(i => i.Id).Contains(user.Id)) + if (_liveTvManager.IsEnabledForUser(user)) { list.Add(_liveTvManager.GetInternalLiveTvFolder(CancellationToken.None)); } @@ -159,6 +152,32 @@ namespace Emby.Server.Implementations.Library .ToArray(); } + private bool HasVisibleChild(Folder folder, User user) + { + // Folder.Children answers this too, but a collection folder delegates it to its physical + // folders, which resolve and then hold on to every child with every field. + var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 + ? collectionFolder.PhysicalFolderIds + : [folder.Id]; + + foreach (var parentId in parentIds) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + ParentId = parentId, + GroupByPresentationUniqueKey = false, + DtoOptions = DtoOptions.StoredColumnsOnly + }); + + if (items.Any(item => item.IsVisible(user))) + { + return true; + } + } + + return false; + } + public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName) { var uniqueId = parentId + "subview" + type; @@ -168,7 +187,7 @@ namespace Emby.Server.Implementations.Library public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName) { - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return GetUserSubViewWithName(name, parentId, type, sortName); } @@ -191,7 +210,7 @@ namespace Emby.Server.Implementations.Library return GetUserView((Folder)parents[0], viewType, string.Empty); } - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return _libraryManager.GetNamedView(user, name, viewType, sortName); } @@ -396,6 +415,12 @@ namespace Emby.Server.Implementations.Library query.Limit = limit; return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies); } + + if (collectionType is null) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown); + } } return _libraryManager.GetItemList(query, parents); diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index fa7112eb90..690466be70 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; @@ -61,6 +62,9 @@ public class ArtistsValidator var count = names.Count; var refreshed = 0; + var liveIds = new HashSet<Guid>(); + var unresolved = 0; + foreach (var name in names) { try @@ -73,13 +77,20 @@ public class ArtistsValidator // Fall back to GetArtist if not found (creates new item if needed) item ??= _libraryManager.GetArtist(name); - var isNew = !existingArtistIds.Contains(item.Id); - var neverRefreshed = item.DateLastRefreshed == default; - if (isNew || neverRefreshed) + // A name with no item is nothing to refresh, and nothing to keep alive either. + if (item is not null) { - await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); - refreshed++; + liveIds.Add(item.Id); + + var isNew = !existingArtistIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; + + if (isNew || neverRefreshed) + { + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } } catch (OperationCanceledException) @@ -88,6 +99,7 @@ public class ArtistsValidator } catch (Exception ex) { + unresolved++; _logger.LogError(ex, "Error refreshing {ArtistName}", name); } @@ -101,13 +113,26 @@ public class ArtistsValidator _logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count); + // Every name that threw is a name whose artist is missing from the live set, and deleting against + // a live set with holes in it deletes artists the library still refers to. Leave the sweep to a + // run that got a clean read of them. + if (unresolved > 0) + { + _logger.LogWarning( + "Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run", + unresolved, + count); + + progress.Report(100); + return; + } + var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicArtist], - IsDeadArtist = true, IsLocked = false - }).Cast<MusicArtist>() - .Where(item => item.IsAccessedByName) + }).OfType<MusicArtist>() + .Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id)) .ToList(); foreach (var item in deadEntities) diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs new file mode 100644 index 0000000000..2cfa446862 --- /dev/null +++ b/Emby.Server.Implementations/Library/Validators/CollectionPosterVerifyPostScanTask.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Validators; + +/// <summary> +/// Ensures top-level library folders have a primary poster after scans. +/// Poster extraction is attempted before library scanning. When a library is +/// empty at that point, no poster can be extracted. This post-scan task reruns +/// metadata extraction for top-level folders that are still missing images. +/// </summary> +public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + public CollectionPosterVerifyPostScanTask( + ILibraryManager libraryManager, + ILogger<CollectionPosterVerifyPostScanTask> logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + /// <summary> + /// Runs the specified progress. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) + { + var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList(); + var totalLibraries = libraries.Count; + var processedLibraries = 0; + + foreach (var library in libraries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!library.HasImage(ImageType.Primary)) + { + _logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name); + await library.RefreshMetadata(cancellationToken).ConfigureAwait(false); + } + + processedLibraries++; + progress.Report((double)processedLibraries / totalLibraries * 100); + } + + progress.Report(100); + } +} diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index dacef102dd..7d53f40ce7 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -1,12 +1,12 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators; @@ -17,104 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators; public class PeopleValidator { /// <summary> - /// The _library manager. + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; /// <summary> - /// The _logger. + /// The logger. /// </summary> - private readonly ILogger _logger; - - private readonly IFileSystem _fileSystem; + private readonly ILogger<PeopleValidator> _logger; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidator" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> - /// <param name="fileSystem">The file system.</param> - public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem) + public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger) { _libraryManager = libraryManager; _logger = logger; - _fileSystem = fileSystem; } /// <summary> /// Validates the people. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress) + public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) { - var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); - - var numComplete = 0; + // Before the refresh below walks them: a credit no item maps to any more stands for nothing, + // and while it is there the person it names cannot reach the dead-person sweep either. + var numOrphaned = _libraryManager.DeleteOrphanedCredits(); + if (numOrphaned > 0) + { + _logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned); + } - var numPeople = people.Count; + var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); + var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Person] + }).ToHashSet(); - IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); + var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds); - _logger.LogDebug("Will refresh {Amount} people", numPeople); + var numComplete = 0; + var count = names.Count; + var refreshed = 0; - foreach (var person in people) + foreach (var name in names) { cancellationToken.ThrowIfCancellationRequested(); try { - var item = _libraryManager.GetPerson(person); - if (item is null) - { - _logger.LogWarning("Failed to get person: {Name}", person); - continue; - } + var item = _libraryManager.GetOrCreatePerson(name); + var isNew = !existingPersonIds.Contains(item.Id); + var neverRefreshed = item.DateLastRefreshed == default; - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + if (isNew || neverRefreshed) { - ImageRefreshMode = MetadataRefreshMode.ValidationOnly, - MetadataRefreshMode = MetadataRefreshMode.ValidationOnly - }; - - await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + await item.RefreshMetadata(cancellationToken).ConfigureAwait(false); + refreshed++; + } } catch (OperationCanceledException) { + // Don't clutter the log throw; } catch (Exception ex) { - _logger.LogError(ex, "Error validating IBN entry {Person}", person); + _logger.LogError(ex, "Error refreshing {PersonName}", name); } - // Update progress numComplete++; double percent = numComplete; - percent /= numPeople; + percent /= count; + percent *= 100; - subProgress.Report(100 * percent); + progress.Report(percent); } - var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Person], - IsDeadPerson = true, - IsLocked = false - }); + _logger.LogInformation( + "Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet", + refreshed, + count, + newNames.Count); - subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + // A person somebody locked is theirs, not ours, however little the library still credits them. + var deadEntities = deadIds + .Select(_libraryManager.GetItemById) + .OfType<Person>() + .Where(item => !item.IsLocked) + .ToList(); - var i = 0; - foreach (var item in deadEntities.Chunk(500)) + foreach (var item in deadEntities) { - _libraryManager.DeleteItemsUnsafeFast(item, true); - subProgress.Report(100f / deadEntities.Count * (i++ * 100)); + _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); } + _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true); + progress.Report(100); + } + + /// <summary> + /// Splits the person items into the ones a credit still calls for and the ones nothing does. + /// </summary> + /// <param name="creditNames">Every name credited on an item, from the people table.</param> + /// <param name="getPersonId">Maps a credit name to the id its person item has.</param> + /// <param name="existingPersonIds">The ids of the person items that exist.</param> + /// <returns>The credits needing an item, and the ids of the items nothing credits.</returns> + internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId( + IReadOnlyList<string> creditNames, + Func<string, Guid> getPersonId, + IReadOnlySet<Guid> existingPersonIds) + { + ArgumentNullException.ThrowIfNull(creditNames); + ArgumentNullException.ThrowIfNull(getPersonId); + ArgumentNullException.ThrowIfNull(existingPersonIds); + + var newNames = new List<string>(); + var liveIds = new HashSet<Guid>(); + + foreach (var name in creditNames) + { + var personId = getPersonId(name); + + // Distinct credit names can normalize onto one id; only the first of them needs an item. + if (liveIds.Add(personId) && !existingPersonIds.Contains(personId)) + { + newNames.Add(name); + } + } + + var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList(); - _logger.LogInformation("People validation complete"); + return (newNames, deadIds); } } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 17af935562..2dc4f5652b 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -100,7 +100,7 @@ "TaskAudioNormalization": "تطبيع الصوت", "TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.", "TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة", - "TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.", + "TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني", "TaskExtractMediaSegments": "فحص مقاطع المحتوى", "TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).", "TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.", "Original": "فريد", - "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}" + "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}", + "NameExtraBehindTheScenes": "خلف المشاهد", + "NameExtraClip": "مقطع", + "NameExtraDeletedScene": "المشهد المحذوف", + "NameExtraInterview": "مقابلة", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "عيّنة", + "NameExtraScene": "مشهد", + "NameExtraShort": "قصير", + "NameExtraThemeSong": "الاغنية السمة", + "NameExtraThemeVideo": "الفيديو السمة", + "NameExtraFeaturette": "فيلم قصير إضافي", + "NameExtraTrailer": "إعلان ترويجي", + "NameExtraUnknown": "إضافي" } diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 5d0ef65842..c5b1213096 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -106,5 +106,17 @@ "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка", - "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." + "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.", + "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}", + "NameExtraDeletedScene": "Выдаленая сцэна", + "NameExtraInterview": "Інтэрв'ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Сцэна", + "NameExtraTrailer": "Трэйлер", + "NameExtraBehindTheScenes": "За кулісамі", + "NameExtraClip": "Кліп", + "NameExtraFeaturette": "Кароткаметражка", + "NameExtraSample": "Прыклад", + "NameExtraShort": "Кароткаметражка", + "NameExtraThemeSong": "Тэматычная песня" } diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 0710a39708..3d49675c63 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.", "TaskExtractMediaSegments": "Сканиране за сегменти", "CleanupUserDataTask": "Задача за почистване на потребителски данни", - "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни." + "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.", + "LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}", + "NameExtraBehindTheScenes": "Зад кулисите", + "NameExtraScene": "Сцена", + "NameExtraShort": "Откъс", + "NameExtraThemeVideo": "Тематично видео", + "NameExtraTrailer": "Трейлър", + "NameExtraUnknown": "Екстра", + "NameExtraClip": "Клип", + "NameExtraDeletedScene": "Изтрита Сцена", + "NameExtraFeaturette": "Кратък филм", + "NameExtraInterview": "Интервю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Пример", + "NameExtraThemeSong": "Тема-песен", + "Original": "Оригинал" } diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json index aa7fe4eb24..5686807d9a 100644 --- a/Emby.Server.Implementations/Localization/Core/bs.json +++ b/Emby.Server.Implementations/Localization/Core/bs.json @@ -106,5 +106,17 @@ "TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay", "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", - "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana." + "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Isječak", + "NameExtraDeletedScene": "Izbrišana scena", + "NameExtraFeaturette": "Kratki prilog", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratko", + "NameExtraThemeSong": "Tema", + "NameExtraThemeVideo": "Tematski video", + "NameExtraTrailer": "Najava" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 6c81726ee6..12076d6c15 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.", "CleanupUserDataTask": "Tasca de neteja de dades d'usuari", "Original": "Original", - "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}" + "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}", + "NameExtraBehindTheScenes": "Rere les càmeres", + "NameExtraClip": "Tall", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Migmetratge", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mostra", + "NameExtraScene": "Escena", + "NameExtraShort": "Curt", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temàtic", + "NameExtraTrailer": "Tràiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 28f0e2df97..cfc16d553c 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -1,6 +1,6 @@ { "AppDeviceValues": "Aplikace: {0}, Zařízení: {1}", - "Artists": "Umělci", + "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspěšně ověřen", "Books": "Knihy", "ChapterNameValue": "Kapitola {0}", @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.", "CleanupUserDataTask": "Pročistit uživatelská data", "Original": "Originál", - "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}" + "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}", + "NameExtraBehindTheScenes": "Zákulisí", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vymazaná scéna", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukázka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátké", + "NameExtraThemeSong": "Úvodní píseň", + "NameExtraThemeVideo": "Úvodní video", + "NameExtraTrailer": "Upoutávka", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index de56b6fd66..6e5532ead9 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -51,7 +51,7 @@ "Shows": "Serier", "StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.", "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}", - "TvShows": "TV-serier", + "TvShows": "Serier", "UserCreatedWithName": "Bruger {0} er blevet oprettet", "UserDeletedWithName": "Brugeren {0} er nu slettet", "UserDownloadingItemWithValues": "{0} henter {1}", @@ -80,8 +80,8 @@ "TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.", "TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.", "TaskRefreshChannels": "Opdatér kanaler", - "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.", - "TaskCleanTranscode": "Tøm omkodningsmappen", + "TaskCleanTranscodeDescription": "Fjerner transkodningsfiler, som er mere end 1 dag gamle.", + "TaskCleanTranscode": "Tøm transkodningsmappen", "TaskRefreshPeople": "Opdatér personer", "TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.", "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Brugerdata oprydningsopgave", "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.", "LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Bag scenerne", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Slettet scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Smagsprøve", + "NameExtraScene": "Scene", + "NameExtraShort": "Kort", + "NameExtraThemeSong": "Temasang", + "NameExtraThemeVideo": "Temavideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Ekstra" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 8ac5fdf6fc..ce5a4c0a6c 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -1,6 +1,6 @@ { "AppDeviceValues": "App: {0}, Gerät: {1}", - "Artists": "Interpreten", + "Artists": "Künstler", "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", "ChapterNameValue": "Kapitel {0}", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.", "Original": "Original", - "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}" + "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraDeletedScene": "Entfernte Szene", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ausschnitt", + "NameExtraScene": "Szene", + "NameExtraShort": "Kurzfilm", + "NameExtraThemeSong": "Titellied", + "NameExtraThemeVideo": "Titelvideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraClip": "Clip", + "NameExtraFeaturette": "Hinter den Kulissen" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index c0ad2c165a..ee03c471ea 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.", "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη", "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}", - "Original": "Πρωτότυπο" + "Original": "Πρωτότυπο", + "NameExtraBehindTheScenes": "Πίσω από τις Σκηνές", + "NameExtraDeletedScene": "Διεγραμμένη Σκηνή", + "NameExtraFeaturette": "Πρόσθετα βίντεο", + "NameExtraInterview": "Συνέντευξη", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Δείγμα", + "NameExtraScene": "Σκηνή", + "NameExtraShort": "Βίντεο μικρού μήκους", + "NameExtraThemeSong": "Θεματικό Τραγούδι", + "NameExtraThemeVideo": "Θεματικό Βίντεο", + "NameExtraTrailer": "τρέιλερ ταινίας", + "NameExtraUnknown": "Πρόσθετα", + "NameExtraClip": "Απόσπασμα" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 298d60d277..a053fc2da9 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -24,8 +24,8 @@ "Music": "Music", "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", + "NameSeasonNumber": "Series {0}", + "NameSeasonUnknown": "Series Unknown", "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", "NotificationOptionApplicationUpdateAvailable": "Application update available", "NotificationOptionApplicationUpdateInstalled": "Application update installed", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "User data cleanup task", "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.", "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 856941c61a..578c85da9d 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -28,6 +28,19 @@ "Movies": "Movies", "Music": "Music", "MusicVideos": "Music Videos", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json index 133a2755a8..e59f089459 100644 --- a/Emby.Server.Implementations/Localization/Core/eo.json +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -97,5 +97,9 @@ "TaskAudioNormalizationDescription": "Skanas dosierojn por sonnivelaj normaligaj datumoj.", "TaskRefreshTrickplayImages": "Generi la bildojn por TrickPlay (Antaŭrigardo rapida antaŭen)", "TaskAudioNormalization": "Normaligo Sonnivela", - "HearingImpaired": "Surda" + "HearingImpaired": "Surda", + "NameExtraDeletedScene": "Forigita sceno", + "NameExtraScene": "Sceno", + "NameExtraThemeSong": "Tema Kanto", + "Original": "Originala" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index bccfdd4c19..a30abb9d4e 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios", "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index ac489b9e77..f4cf45cb12 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.", "CleanupUserDataTask": "Tarea de limpieza de los datos del usuario", - "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días." + "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días.", + "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 563dce8fe6..9e82e0601b 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Tarea de limpieza de datos del usuario", "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.", "Original": "Original", - "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}" + "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás de Cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Reportaje especial", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Tema principal", + "NameExtraThemeVideo": "Vídeo del tema principal", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 4404354a88..274c60c7bc 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Tarea de limpieza de datos de usuario", "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.", "LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}", - "Original": "Original" + "Original": "Original", + "NameExtraUnknown": "Extra", + "NameExtraBehindTheScenes": "Detrás de cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena eliminada", + "NameExtraFeaturette": "Minidocumental", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Muestra", + "NameExtraScene": "Escena", + "NameExtraShort": "Cortometraje", + "NameExtraThemeSong": "Música de presentación", + "NameExtraThemeVideo": "Video de presentación", + "NameExtraTrailer": "Tráiler" } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index e6bf1f25b5..a7afcf5b77 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Puhasta kasutajaandmed", "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.", "LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus", - "Original": "Algne" + "Original": "Algne", + "NameExtraBehindTheScenes": "Kulisside taga", + "NameExtraDeletedScene": "Väljajäetud stseen", + "NameExtraFeaturette": "Lisalõik", + "NameExtraInterview": "Intervjuu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näidis", + "NameExtraScene": "Stseen", + "NameExtraShort": "Lühifilm", + "NameExtraThemeSong": "Tunnusmeloodia", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Treiler", + "NameExtraUnknown": "Lisamaterjal", + "NameExtraClip": "Videoklipp" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index 71c351adcd..df6855d6e3 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.", "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina", "LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik", - "Original": "Jatorrizkoa" + "Original": "Jatorrizkoa", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Lagina", + "NameExtraScene": "Eszena", + "NameExtraShort": "Laburra", + "NameExtraThemeSong": "Gai-abestia", + "NameExtraThemeVideo": "Gai-bideoa", + "NameExtraBehindTheScenes": "Eszenen atzean", + "NameExtraClip": "Klipa", + "NameExtraDeletedScene": "Ezabatutako eszena", + "NameExtraFeaturette": "Erreportajea", + "NameExtraInterview": "Elkarrizketa", + "NameExtraTrailer": "Trailerra", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index d080eb0236..9a453120dd 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä", "CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään.", "LyricDownloadFailureFromForItem": "Sanoitusten lataus kohteesta {0} kappaleelle {1} epäonnistui", - "Original": "Alkuperäinen" + "Original": "Alkuperäinen", + "NameExtraBehindTheScenes": "Kulissien Takana", + "NameExtraClip": "Klippi", + "NameExtraDeletedScene": "Poistettu Kohtaus", + "NameExtraFeaturette": "Lyhytelokuva", + "NameExtraInterview": "Haastattelu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Näyte", + "NameExtraScene": "Kohtaus", + "NameExtraShort": "Lyhytfilmi", + "NameExtraThemeSong": "Tunnusmusiikki", + "NameExtraThemeVideo": "Tunnusvideo", + "NameExtraTrailer": "Traileri" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 4fb9f4329c..acb78290f3 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -1,16 +1,125 @@ { - "Artists": "Listafólk", - "Collections": "Søvn", - "Default": "Sjálvgildi", + "Artists": "Tónlistafólk", + "Collections": "Samlingar", + "Default": "Forsett", "External": "Ytri", "Genres": "Greinar", "AppDeviceValues": "App: {0}, Eind: {1}", "Books": "Bøkur", "ChapterNameValue": "Kapittul {0}", - "Favorites": "Yndis", - "Folders": "Mappur", + "Favorites": "Yndislisti", + "Folders": "Skjáttur", "Forced": "Kravt", "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", "HeaderFavoriteEpisodes": "Yndispartar", - "LabelIpAddressValue": "IP atsetur: {0}" + "LabelIpAddressValue": "IP-atsetur: {0}", + "AuthenticationSucceededWithUserName": "{0} var samgildur", + "HeaderFavoriteShows": "Yndisrøðir", + "HeaderLiveTV": "Beinleiðis sjónvarp", + "HearingImpaired": "Hoyrnarveik", + "Inherit": "Arvar", + "LabelRunningTimeValue": "Spælitíð: {0}", + "Latest": "Seinastu", + "LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}", + "NameInstallFailed": "{0} innlegging miseydnaðist", + "NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.", + "NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt", + "NotificationOptionPluginInstalled": "Ískoytisforrit innlagt", + "NotificationOptionPluginUninstalled": "Ískoytisforrit er strikað", + "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført", + "NotificationOptionUserLockedOut": "Brúkari útihýstur", + "Photos": "Ljósmyndir", + "PluginInstalledWithName": "{0} innlagt", + "PluginUninstalledWithName": "{0} strikað", + "PluginUpdatedWithName": "{0} dagført", + "Shows": "Røðir", + "SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}", + "TvShows": "Sjónvarpsrøðir", + "UserCreatedWithName": "Brúkari {0} er stovnaður", + "UserDeletedWithName": "Brúkari {0} er strikaður", + "UserDownloadingItemWithValues": "{0} niðurtekur {1}", + "UserLockedOutWithName": "Brúkari {0} er útihýstur", + "VersionNumber": "Útgáva {0}", + "TasksLibraryCategory": "Savn", + "TaskRefreshLibrary": "Skanna miðlasavn", + "TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.", + "TaskUpdatePlugins": "Dagfør ískoytisforrit", + "TaskRefreshChannels": "Endurinnles rásir", + "TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir", + "Movies": "Filmar", + "MixedContent": "Blandað innihald", + "Music": "Tónleikur", + "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}", + "HeaderContinueWatching": "Hald áfram at hyggja", + "MusicVideos": "Sjónbandaløg", + "TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.", + "TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.", + "TaskOptimizeDatabase": "Albøt dátugrunn", + "NameSeasonNumber": "Umfar {0}", + "NameSeasonUnknown": "Ókent umfar", + "ScheduledTaskFailedWithName": "{0} miseydnaðist", + "Undefined": "Óskilmarkað", + "TasksMaintenanceCategory": "Viðlíkahald", + "TaskCleanLogs": "Reinsa gerðalistaskjáttu", + "UserOnlineFromDevice": "{0} er íbundin frá {1}", + "HeaderNextUp": "Næst á skránni", + "NotificationOptionPluginError": "Brek í ískoytisforriti", + "NotificationOptionInstallationFailed": "Innleggingarbrek", + "NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan", + "TasksApplicationCategory": "Nýtsluskipan", + "NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk", + "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd", + "UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}", + "HomeVideos": "Heimaupptøkur", + "StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.", + "UserOfflineFromDevice": "{0} breyt av frá {1}", + "UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt", + "TasksChannelsCategory": "Alnetsrásir", + "TaskCleanActivityLog": "Reinsa virksemisskrá", + "TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.", + "TaskCleanCache": "Reinsa kovaskjáttu", + "TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.", + "TaskCleanTranscode": "Reinsa umkotuskjáttu", + "TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir", + "TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir", + "CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.", + "CleanupUserDataTask": "Reinsa brúkaradátur", + "TaskRefreshPeople": "Dagfør persónsupplýsingar", + "TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.", + "TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.", + "TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.", + "NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu", + "TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.", + "TaskKeyframeExtractor": "Lyklamyndaúttøka", + "TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.", + "TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.", + "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", + "TaskRefreshChapterImages": "Kapitlamyndaúttøkur", + "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", + "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl er steðgað", + "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl er steðgað", + "TaskExtractMediaSegments": "Leita eftir margmiðlabrotum", + "TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.", + "NotificationOptionCameraImageUploaded": "Ljósmynd uppsend", + "NameExtraShort": "Stuttfilmur", + "NameExtraThemeSong": "Eyðkennislag", + "NameExtraTrailer": "Brellbiti", + "NameExtraInterview": "Samrøða", + "NameExtraBehindTheScenes": "Aftanfyri leiktjøldini", + "NameExtraClip": "Klipp", + "NameExtraNumbered": "{0} {1}", + "NameExtraFeaturette": "Stuttur heimildarfilmur", + "TaskAudioNormalization": "Ljóðjavnan", + "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.", + "NameExtraSample": "Sýnislutur", + "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir", + "TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.", + "TaskMoveTrickplayImages": "Flyt Trickplay-myndir", + "TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.", + "NameExtraThemeVideo": "Eyðkenniskykmynd", + "NameExtraDeletedScene": "Úrtikin mynd", + "NameExtraScene": "Mynd", + "NameExtraUnknown": "Eykatilfar", + "Original": "Upprunalig(t/ur)" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index e05cce47b0..393af79ab8 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", "LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Chanson thème", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index ceba1dcb41..858fd9eff6 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", "LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Dans Les Coulisses", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scène supprimée", + "NameExtraFeaturette": "Court-métrage", + "NameExtraInterview": "Entrevue", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Échantillon", + "NameExtraScene": "Scène", + "NameExtraShort": "Court-métrage", + "NameExtraThemeSong": "Thème musical", + "NameExtraThemeVideo": "Générique", + "NameExtraTrailer": "Bande-annonce", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 1ee606cc64..30e11d15f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora", "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.", "Original": "Bunaidh", - "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}" + "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}", + "NameExtraBehindTheScenes": "Taobh thiar de na Radhairc", + "NameExtraClip": "Gearrthóg", + "NameExtraDeletedScene": "Radharc Scriosta", + "NameExtraFeaturette": "Mionghné", + "NameExtraInterview": "Agallamh", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sampla", + "NameExtraScene": "Radharc", + "NameExtraShort": "Gearr", + "NameExtraThemeSong": "Amhrán Téama", + "NameExtraThemeVideo": "Físeán Téama", + "NameExtraTrailer": "Leantóir" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index a68db0076a..9eb9786b8a 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -107,5 +107,19 @@ "TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.", "CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios", "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.", - "Original": "Orixinal" + "Original": "Orixinal", + "LyricDownloadFailureFromForItem": "Non se puideron descargar as letras desde {0} para {1}", + "NameExtraBehindTheScenes": "Detrás das Cámaras", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Escena Eliminada", + "NameExtraFeaturette": "Reportaxe especial", + "NameExtraInterview": "Entrevista", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mostra", + "NameExtraScene": "Escena", + "NameExtraShort": "Curtametraxe", + "NameExtraThemeSong": "Canción principal", + "NameExtraThemeVideo": "Vídeo da canción principal", + "NameExtraTrailer": "Tráiler", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 48056b0bb8..04877b18a7 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.", "CleanupUserDataTask": "משימת ניקוי מידע משתמש", "LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה", - "Original": "מקור" + "Original": "מקור", + "NameExtraBehindTheScenes": "מאחורי הקלעים", + "NameExtraClip": "קליפ", + "NameExtraDeletedScene": "סצנה שנמחקה", + "NameExtraFeaturette": "סרט עלילה קצר", + "NameExtraInterview": "ריאיון", + "NameExtraSample": "דגימה", + "NameExtraScene": "סצנה", + "NameExtraThemeSong": "שיר נושא", + "NameExtraThemeVideo": "סרטון נושא", + "NameExtraTrailer": "קדימון", + "NameExtraUnknown": "נוסף", + "NameExtraNumbered": "{0} {1}", + "NameExtraShort": "סרט קצר" } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index e98a5fbac1..f4b1f86d1d 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -105,5 +105,21 @@ "TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।", "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें", "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।", - "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य" + "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य", + "Original": "असली", + "LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा", + "NameExtraBehindTheScenes": "परदे के पीछे", + "NameExtraClip": "क्लिप", + "NameExtraDeletedScene": "हटाया गया दृश्य", + "NameExtraFeaturette": "फीचरेट", + "NameExtraInterview": "साक्षात्कार", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "नमूना", + "NameExtraScene": "दृश्य", + "NameExtraShort": "शॉर्ट", + "NameExtraThemeSong": "थीम सॉन्ग", + "NameExtraThemeVideo": "थीम वीडियो", + "NameExtraTrailer": "ट्रेलर", + "NameExtraUnknown": "अतिरिक्त", + "CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 8794339fb1..c7e2b5f5e9 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -107,5 +107,19 @@ "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.", "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka", "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo", + "NameExtraBehindTheScenes": "Iza kulisa", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Obrisana Scena", + "NameExtraFeaturette": "Promotivni video", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Uzorak", + "NameExtraScene": "Scena", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Glavna Pjesma", + "NameExtraThemeVideo": "Tema videa", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Dodatno" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 1995d7a4cf..4a327e7b02 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.", "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat", "Original": "Eredeti", - "LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen" + "LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen", + "NameExtraBehindTheScenes": "Színfalak mögött", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Törölt jelenet", + "NameExtraFeaturette": "Kísérő film", + "NameExtraInterview": "Interjú", + "NameExtraSample": "Minta", + "NameExtraScene": "Jelenet", + "NameExtraShort": "Rövidfilm", + "NameExtraThemeSong": "Főcímdal", + "NameExtraThemeVideo": "Főcímvideó", + "NameExtraTrailer": "Előzetes", + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json index b79b540bf4..80be71ed70 100644 --- a/Emby.Server.Implementations/Localization/Core/hy.json +++ b/Emby.Server.Implementations/Localization/Core/hy.json @@ -24,5 +24,10 @@ "TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը", "AppDeviceValues": "Հավելված` {0}, Սարք `{1}", "ChapterNameValue": "Գլուխ {0}", - "Collections": "Հավաքածուներ" + "Collections": "Հավաքածուներ", + "Artists": "Երաժիշտներ", + "Default": "Լռելյայն", + "Favorites": "Ընտրյալ", + "Forced": "Ստիպուած", + "Genres": "Ոճ" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index 44e057e4de..a25857e510 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -62,7 +62,7 @@ "UserDownloadingItemWithValues": "{0} hleður niður {1}", "SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}", "Shows": "Þættir", - "TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.", + "TaskRefreshChannelsDescription": "Endurhleður upplýsingum netrása.", "TaskRefreshChannels": "Endurhlaða Rásir", "TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.", "TaskCleanTranscode": "Hreinsa Umkóðunarmöppu", @@ -80,7 +80,7 @@ "TasksMaintenanceCategory": "Viðhald", "Default": "Sjálfgefið", "TaskCleanActivityLog": "Hreinsa athafnaskrá", - "TaskRefreshPeople": "Endurnýja fólk", + "TaskRefreshPeople": "Endurnýja upplýsingar um fólk", "TaskDownloadMissingSubtitles": "Sækja texta sem vantar", "TaskOptimizeDatabase": "Fínstilla gagnagrunn", "Undefined": "Óskilgreint", @@ -95,8 +95,8 @@ "TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.", "Forced": "Þvingað", "External": "Útvær", - "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.", - "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir", + "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir (Trickplay) fyrir myndbönd í virkum söfnum.", + "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir (Trickplay)", "TaskAudioNormalization": "Hljóðstöðlun", "TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.", "TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög", @@ -106,5 +106,20 @@ "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.", "LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}", "Original": "Upprunaleg", - "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt." + "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.", + "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir", + "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.", + "NameExtraBehindTheScenes": "Bak við tjöldin", + "NameExtraClip": "Brot", + "NameExtraDeletedScene": "Eydd atriði", + "NameExtraFeaturette": "Stutt heimildarmynd", + "NameExtraInterview": "Viðtal", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sýnishorn", + "NameExtraScene": "Sena", + "NameExtraShort": "Stuttmynd", + "NameExtraThemeSong": "Þema lag", + "NameExtraThemeVideo": "Þema myndband", + "NameExtraTrailer": "Stikla", + "NameExtraUnknown": "Aukaefni" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index f13944e6be..41097ed92e 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Task di pulizia dei dati utente", "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.", "Original": "Originale", - "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}" + "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}", + "NameExtraBehindTheScenes": "Dietro le scene", + "NameExtraClip": "Filmato", + "NameExtraDeletedScene": "Scena eliminata", + "NameExtraInterview": "Intervista", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scena", + "NameExtraSample": "Campione", + "NameExtraShort": "Corto", + "NameExtraThemeSong": "Sigla musicale", + "NameExtraTrailer": "Trailer", + "NameExtraFeaturette": "Caratteristica", + "NameExtraThemeVideo": "Video tematico", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index a210125d34..1f16a90842 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -108,5 +108,8 @@ "CleanupUserDataTask": "사용자 데이터 정리 작업", "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", - "Original": "원본" + "Original": "원본", + "NameExtraClip": "클립", + "NameExtraDeletedScene": "삭제된 장면", + "NameExtraInterview": "인터뷰" } diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json index e94709b083..1e64a130eb 100644 --- a/Emby.Server.Implementations/Localization/Core/lb.json +++ b/Emby.Server.Implementations/Localization/Core/lb.json @@ -83,7 +83,7 @@ "NewVersionIsAvailable": "Nei Versioun fir Jellyfin Server ass verfügbar.", "PluginInstalledWithName": "{0} installéiert", "TaskMoveTrickplayImagesDescription": "Verschëfft existent Trickplay-Dateien no de Bibliothéik-Astellungen.", - "AppDeviceValues": "App: {0}, Geräter: {1}", + "AppDeviceValues": "App: {0}, Apparater: {1}", "FailedLoginAttemptWithUserName": "Net Gelongen Umeldung {0}", "HeaderLiveTV": "LiveTV", "NotificationOptionServerRestartRequired": "Server Restart Erfuerderlech", @@ -104,5 +104,21 @@ "TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.", "TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.", "TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.", - "TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren." + "TaskOptimizeDatabaseDescription": "Kompriméiert d’Datebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann d’Performance verbesseren.", + "LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}", + "Original": "Original", + "CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten", + "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.", + "NameExtraBehindTheScenes": "Hannert de Kulissen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geläschte Scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Beispill", + "NameExtraScene": "Scène", + "NameExtraShort": "Kuerzfilm", + "NameExtraThemeSong": "Theme-Lidd", + "NameExtraThemeVideo": "Theme-Video", + "NameExtraTrailer": "Bande-Annonce" } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index ed26004a43..db4710ddca 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -3,15 +3,15 @@ "Artists": "Atlikėjai", "AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota", "Books": "Knygos", - "ChapterNameValue": "Scena{0}", - "Collections": "Rinkiniai", + "ChapterNameValue": "Skyrius{0}", + "Collections": "Kolekcijos", "FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti", "Favorites": "Mėgstami", - "Folders": "Katalogai", + "Folders": "Aplankai", "Genres": "Žanrai", "HeaderContinueWatching": "Žiūrėti toliau", - "HeaderFavoriteEpisodes": "Mėgstamiausios serijos", - "HeaderFavoriteShows": "Mėgstamiausios TV Laidos", + "HeaderFavoriteEpisodes": "Mėgstami Epizodai", + "HeaderFavoriteShows": "Mėgstamos TV Laidos", "HeaderLiveTV": "Tiesioginė TV", "HeaderNextUp": "Toliau", "HomeVideos": "Namų vaizdo įrašai", @@ -26,14 +26,14 @@ "NameInstallFailed": "{0} diegimo klaida", "NameSeasonNumber": "Sezonas {0}", "NameSeasonUnknown": "Sezonas neatpažintas", - "NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.", + "NewVersionIsAvailable": "Nauja Jellyfin Server versija yra prieinama atsisiuntimui.", "NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai", "NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti", "NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas", "NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas", - "NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti", + "NotificationOptionCameraImageUploaded": "Kameros atvaizdai įkelti", "NotificationOptionInstallationFailed": "Diegimo klaida", - "NotificationOptionNewLibraryContent": "Naujas turinys įkeltas", + "NotificationOptionNewLibraryContent": "Pridėtas naujas turinys", "NotificationOptionPluginError": "Įskiepio klaida", "NotificationOptionPluginInstalled": "Įskiepis įdiegtas", "NotificationOptionPluginUninstalled": "Įskiepis išdiegtas", @@ -51,7 +51,7 @@ "Shows": "Laidos", "StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.", "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", - "TvShows": "TV laidos", + "TvShows": "TV Laidos", "UserCreatedWithName": "Buvo sukurtas {0} naudotojas", "UserDeletedWithName": "Naudotojas {0} ištrintas", "UserDownloadingItemWithValues": "{0} siunčiasi {1}", @@ -59,14 +59,14 @@ "UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}", "UserOnlineFromDevice": "{0} prisijungęs iš {1}", "UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}", - "UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}", - "UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}", + "UserStartedPlayingItemWithValues": "{0} atkuriama {1} į {2}", + "UserStoppedPlayingItemWithValues": "{0} baigė atkūrimą {1} į {2}", "VersionNumber": "Versija {0}", "TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.", "TaskUpdatePlugins": "Atnaujinti įskieius", "TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.", - "TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.", - "TaskCleanTranscode": "Išvalyti perkodavimo katalogą", + "TaskCleanTranscodeDescription": "Ištrina dienos senumo transkodavimo failus.", + "TaskCleanTranscode": "Išvalyti transkodavimo katalogą", "TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.", "TaskRefreshLibrary": "Skenuoti medijos biblioteką", "TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus", @@ -76,8 +76,8 @@ "TaskRefreshPeople": "Atnaujinti žmones", "TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.", "TaskCleanLogs": "Išvalyti žurnalą", - "TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.", - "TaskRefreshChapterImages": "Ištraukti skyrių vaizdus", + "TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašams, kuriuose yra skyriai.", + "TaskRefreshChapterImages": "Ištraukti skyrių atvaizdus", "TaskCleanCache": "Išvalyti talpyklą", "TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.", "TasksChannelsCategory": "Internetiniai kanalai", @@ -87,7 +87,7 @@ "TaskCleanActivityLog": "Išvalyti veiklos žurnalą", "Undefined": "Neapibrėžtas", "Forced": "Priverstinis", - "Default": "Numatytas", + "Default": "Numatytasis", "TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.", "TaskOptimizeDatabase": "Optimizuoti duomenų bazę", "TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.", @@ -96,15 +96,30 @@ "External": "Išorinis", "HearingImpaired": "Su klausos sutrikimais", "TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus", - "TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.", + "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įjungtose bibliotekose, Trickplay peržiūras.", "TaskAudioNormalization": "Garso normalizavimas", "TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.", "TaskExtractMediaSegments": "Medijos segmentų nuskaitymas", - "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus", + "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus", "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.", - "TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą", - "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.", - "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius", + "TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą", + "TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.", + "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus", "CleanupUserDataTask": "Naudotojo duomenų valymo užduotis", - "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)." + "CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).", + "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}", + "NameExtraBehindTheScenes": "Užkulisiuose", + "NameExtraClip": "Klipas", + "NameExtraDeletedScene": "Ištrinta scena", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Pavyzdys", + "NameExtraScene": "Scena", + "NameExtraThemeSong": "Teminė daina", + "NameExtraThemeVideo": "Teminis vaizdo įrašas", + "NameExtraTrailer": "Anonsas", + "NameExtraUnknown": "Papildomas", + "Original": "Originalus", + "NameExtraFeaturette": "Trumpametražis filmas", + "NameExtraShort": "Trumpas filmukas" } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 4a1b248e76..52f1eecbe4 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -107,5 +107,18 @@ "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", - "Original": "Oriģināls" + "Original": "Oriģināls", + "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}", + "NameExtraBehindTheScenes": "Aiz kadra", + "NameExtraClip": "Klips", + "NameExtraDeletedScene": "Izdzēsta aina", + "NameExtraFeaturette": "Īsfilma", + "NameExtraInterview": "Intervija", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Paraugs", + "NameExtraScene": "Aina", + "NameExtraShort": "Īsfilma", + "NameExtraThemeSong": "Motīvu dziesma", + "NameExtraThemeVideo": "Tēmas video", + "NameExtraTrailer": "Treileris" } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 9fc61fadd5..bb4469ad9c 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -106,5 +106,11 @@ "TaskAudioNormalization": "Normalisasi Audio", "TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.", "CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.", - "CleanupUserDataTask": "Tugas pembersihan data pengguna" + "CleanupUserDataTask": "Tugas pembersihan data pengguna", + "LyricDownloadFailureFromForItem": "Lirik gagal dimuat turun dari {0} untuk {1}", + "NameExtraBehindTheScenes": "Di Sebalik Takbir", + "NameExtraClip": "Klip", + "NameExtraInterview": "Temu bual", + "NameExtraThemeSong": "Lagu Tema", + "NameExtraThemeVideo": "Video Tema" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 752b74ec1c..735bc2c793 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -106,5 +106,15 @@ "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.", "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.", "CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.", - "CleanupUserDataTask": "Oppgave for opprydding av brukerdata" + "CleanupUserDataTask": "Oppgave for opprydding av brukerdata", + "NameExtraBehindTheScenes": "Bak kulissene", + "NameExtraDeletedScene": "Slettet scene", + "NameExtraFeaturette": "Presentasjonsfilm", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Prøve", + "NameExtraScene": "Scene", + "NameExtraThemeSong": "Tema-låt", + "NameExtraThemeVideo": "Tema-video", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 9aea3adc22..28ac66e70d 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Opruimtaak gebruikersdata", "Genres": "Genres", "Original": "Oorspronkelijk", - "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt" + "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt", + "NameExtraBehindTheScenes": "Achter de schermen", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Geschrapte scène", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Vraaggesprek", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Voorbeeldfragment", + "NameExtraScene": "Scène", + "NameExtraShort": "Korte film", + "NameExtraThemeSong": "Themamuziek", + "NameExtraThemeVideo": "Themavideo", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra inhoud" } diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json index cad5640763..4f573b3c58 100644 --- a/Emby.Server.Implementations/Localization/Core/oc.json +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -1,3 +1,27 @@ { - "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}" + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}", + "Books": "Libres", + "Artists": "Artistas", + "Collections": "Collecciones", + "ChapterNameValue": "Capitol {0}", + "External": "Extèrn", + "Folders": "Dorsièrs", + "Favorites": "Favorits", + "HeaderContinueWatching": "Contunhar de regardar", + "HeaderFavoriteEpisodes": "Episòdis Favorits", + "AuthenticationSucceededWithUserName": "{0} autentificat amb succès", + "HeaderFavoriteShows": "Serias Favoritas", + "HeaderLiveTV": "TV en dirècte", + "HeaderNextUp": "Seguent", + "HearingImpaired": "Amb de deficiéncias auditivas", + "Movies": "Filmes", + "Music": "Musica", + "Latest": "Darrièr", + "Forced": "Forçat", + "Default": "Defaut", + "Genres": "Genres", + "HomeVideos": "Vidèos d'Acuèlh", + "Inherit": "Eiretar", + "LabelIpAddressValue": "Adreça IP: {0}", + "LabelRunningTimeValue": "Temps d'execucion : {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index e609ad52c8..39bbe8bb55 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -106,5 +106,6 @@ "TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।", "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।", "CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।", - "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ" + "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ", + "LyricDownloadFailureFromForItem": "{1} ਲਈ {0} ਤੋਂ ਬੋਲ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ।" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index c4657bdd6e..71909fd73a 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika", "Original": "Oryginalny", - "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}" + "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}", + "NameExtraBehindTheScenes": "Za kulisami", + "NameExtraClip": "Urywek", + "NameExtraDeletedScene": "Usunięta scena", + "NameExtraFeaturette": "Film średniometrażowy", + "NameExtraInterview": "Wywiad", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Fragment", + "NameExtraScene": "Scena", + "NameExtraShort": "Film krótkometrażowy", + "NameExtraThemeSong": "Czołówka", + "NameExtraThemeVideo": "Wideo wprowadzające", + "NameExtraTrailer": "Zwiastun", + "NameExtraUnknown": "Dodatek" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 1db500adf3..997d534fea 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Tarefa de limpeza de dados do usuário", "CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.", "LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Nos Bastidores", + "NameExtraClip": "Clipe", + "NameExtraDeletedScene": "cena Extra", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Trecho", + "NameExtraScene": "Cena", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Música Tema", + "NameExtraThemeVideo": "Vídeo de Abertura", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", + "NameExtraFeaturette": "Nos Bastidores", + "NameExtraInterview": "Entrevista" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index ce7f6d120e..e71475afd8 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -5,108 +5,121 @@ "Books": "Livros", "ChapterNameValue": "Capítulo {0}", "Collections": "Coleções", - "FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}", + "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}", "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Géneros", "HeaderContinueWatching": "Continuar a ver", - "HeaderFavoriteEpisodes": "Episódios Favoritos", - "HeaderFavoriteShows": "Séries Favoritas", - "HeaderLiveTV": "TV em Direto", - "HeaderNextUp": "A Seguir", - "HomeVideos": "Vídeos Caseiros", + "HeaderFavoriteEpisodes": "Episódios favoritos", + "HeaderFavoriteShows": "Séries favoritas", + "HeaderLiveTV": "TV em direto", + "HeaderNextUp": "A seguir", + "HomeVideos": "Vídeos caseiros", "Inherit": "Herdar", "LabelIpAddressValue": "Endereço IP: {0}", "LabelRunningTimeValue": "Duração: {0}", - "Latest": "Mais Recente", - "MixedContent": "Conteúdo Misto", + "Latest": "Mais recente", + "MixedContent": "Conteúdo misto", "Movies": "Filmes", "Music": "Música", "MusicVideos": "Videoclipes", - "NameInstallFailed": "{0} falha na instalação", + "NameInstallFailed": "Falha na instalação de {0}", "NameSeasonNumber": "Temporada {0}", - "NameSeasonUnknown": "Temporada Desconhecida", + "NameSeasonUnknown": "Temporada desconhecida", "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.", "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível", "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada", - "NotificationOptionAudioPlayback": "Reprodução Iniciada", - "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", + "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada", + "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida", "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", "NotificationOptionInstallationFailed": "Falha na instalação", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", - "NotificationOptionPluginError": "Falha na extensão", - "NotificationOptionPluginInstalled": "Extensão instalada", - "NotificationOptionPluginUninstalled": "Extensão desinstalada", - "NotificationOptionPluginUpdateInstalled": "Extensão atualizada", + "NotificationOptionPluginError": "Falha no plugin", + "NotificationOptionPluginInstalled": "Plugin instalado", + "NotificationOptionPluginUninstalled": "Plugin desinstalado", + "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada", "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor", "NotificationOptionTaskFailed": "Falha em tarefa agendada", "NotificationOptionUserLockedOut": "Utilizador bloqueado", - "NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada", - "NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada", + "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida", "Photos": "Fotografias", "PluginInstalledWithName": "{0} foi instalado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginUpdatedWithName": "{0} foi atualizado", "ScheduledTaskFailedWithName": "{0} falhou", "Shows": "Séries", - "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.", - "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}", - "TvShows": "Séries", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.", + "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}", + "TvShows": "Séries de TV", "UserCreatedWithName": "Utilizador {0} criado", - "UserDeletedWithName": "Utilizador {0} apagado", + "UserDeletedWithName": "Utilizador {0} eliminado", "UserDownloadingItemWithValues": "{0} está a transferir {1}", "UserLockedOutWithName": "Utilizador {0} bloqueado", - "UserOfflineFromDevice": "{0} desligou-se a partir de {1}", - "UserOnlineFromDevice": "{0} ligou-se a partir de {1}", + "UserOfflineFromDevice": "{0} desligou-se de {1}", + "UserOnlineFromDevice": "{0} está online em {1}", "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", "VersionNumber": "Versão {0}", - "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.", + "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.", "TaskDownloadMissingSubtitles": "Transferir legendas em falta", - "TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.", - "TaskRefreshChannels": "Atualizar Canais", - "TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.", - "TaskCleanTranscode": "Limpar a Diretoria de Transcode", - "TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.", - "TaskUpdatePlugins": "Atualizar Plugins", - "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.", - "TaskRefreshPeople": "Atualizar Pessoas", - "TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.", - "TaskCleanLogs": "Limpar a Diretoria de Logs", - "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.", - "TaskRefreshLibrary": "Analisar mediateca", - "TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.", - "TaskRefreshChapterImages": "Extrair Imagens dos Capítulos", - "TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.", - "TaskCleanCache": "Limpar Cache", + "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.", + "TaskRefreshChannels": "Atualizar canais", + "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.", + "TaskCleanTranscode": "Limpar pasta de transcodificação", + "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.", + "TaskUpdatePlugins": "Atualizar plugins", + "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.", + "TaskRefreshPeople": "Atualizar pessoas", + "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.", + "TaskCleanLogs": "Limpar pasta de registos", + "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.", + "TaskRefreshLibrary": "Analisar biblioteca multimédia", + "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", + "TaskRefreshChapterImages": "Extrair imagens dos capítulos", + "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.", + "TaskCleanCache": "Limpar pasta de cache", "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicação", - "TasksLibraryCategory": "Mediateca", + "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manutenção", - "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.", + "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.", "TaskCleanActivityLog": "Limpar registo de atividade", "Undefined": "Indefinido", "Forced": "Forçado", "Default": "Predefinição", - "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", + "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.", "TaskOptimizeDatabase": "Otimizar base de dados", - "TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.", - "TaskKeyframeExtractor": "Extrator de Quadros-chave", + "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.", + "TaskKeyframeExtractor": "Extrator de fotogramas-chave", "External": "Externo", - "HearingImpaired": "Surdo", + "HearingImpaired": "Deficiência auditiva", "TaskRefreshTrickplayImages": "Gerar imagens de trickplay", "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", "TaskAudioNormalization": "Normalização de áudio", - "TaskExtractMediaSegments": "Analisar segmentos de multimédia", - "TaskDownloadMissingLyrics": "Transferir letra em falta", - "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", - "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", - "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", - "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", + "TaskExtractMediaSegments": "Analisar segmentos multimédia", + "TaskDownloadMissingLyrics": "Transferir letras em falta", + "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay", + "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.", + "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.", + "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.", "CleanupUserDataTask": "Limpeza de dados de utilizador", "Original": "Original", - "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" + "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}", + "NameExtraDeletedScene": "Cena eliminada", + "NameExtraInterview": "Entrevista", + "NameExtraUnknown": "Extra", + "NameExtraBehindTheScenes": "Bastidores", + "NameExtraSample": "Amostra", + "NameExtraScene": "Cena", + "NameExtraClip": "Excerto", + "NameExtraFeaturette": "Minidocumentário", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temático", + "NameExtraShort": "Curta-metragem", + "NameExtraNumbered": "{0} {1}", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index ce338acf34..8299a25992 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -1,112 +1,125 @@ { - "HeaderLiveTV": "TV Em Direto", + "HeaderLiveTV": "TV em direto", "Collections": "Coleções", "Books": "Livros", "Artists": "Artistas", - "HeaderNextUp": "A Seguir", - "HeaderFavoriteEpisodes": "Episódios Favoritos", - "HeaderFavoriteShows": "Séries Favoritas", + "HeaderNextUp": "A seguir", + "HeaderFavoriteEpisodes": "Episódios favoritos", + "HeaderFavoriteShows": "Séries favoritas", "HeaderContinueWatching": "Continuar a ver", "Genres": "Géneros", "Folders": "Pastas", "Favorites": "Favoritos", - "UserDownloadingItemWithValues": "{0} está sendo baixado {1}", + "UserDownloadingItemWithValues": "{0} está a transferir {1}", "VersionNumber": "Versão {0}", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", - "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", - "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada", - "UserOnlineFromDevice": "{0} está online a partir de {1}", - "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}", - "UserLockedOutWithName": "O usuário {0} foi bloqueado", - "UserDeletedWithName": "O usuário {0} foi removido", - "UserCreatedWithName": "O usuário {0} foi criado", - "TvShows": "Séries", - "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}", - "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.", + "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", + "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", + "UserOnlineFromDevice": "{0} está online em {1}", + "UserOfflineFromDevice": "{0} desligou-se de {1}", + "UserLockedOutWithName": "Utilizador {0} bloqueado", + "UserDeletedWithName": "Utilizador {0} eliminado", + "UserCreatedWithName": "Utilizador {0} criado", + "TvShows": "Séries de TV", + "SubtitleDownloadFailureFromForItem": "Falha ao transferir legendas de {0} para {1}", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tenta novamente dentro de instantes.", "ScheduledTaskFailedWithName": "{0} falhou", "PluginUpdatedWithName": "{0} foi atualizado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginInstalledWithName": "{0} foi instalado", - "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo interrompida", "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", - "NotificationOptionUserLockedOut": "Usuário bloqueado", - "NotificationOptionTaskFailed": "Falha na tarefa agendada", - "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor", - "NotificationOptionPluginUpdateInstalled": "Plugin atualizado", + "NotificationOptionUserLockedOut": "Utilizador bloqueado", + "NotificationOptionTaskFailed": "Falha em tarefa agendada", + "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor", + "NotificationOptionPluginUpdateInstalled": "Atualização de plugin instalada", "NotificationOptionPluginUninstalled": "Plugin desinstalado", "NotificationOptionPluginInstalled": "Plugin instalado", "NotificationOptionPluginError": "Falha no plugin", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", - "NotificationOptionInstallationFailed": "Falha de instalação", - "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada", - "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", - "NotificationOptionAudioPlayback": "Reprodução Iniciada", - "NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada", - "NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível", - "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.", - "NameSeasonUnknown": "Temporada Desconhecida", + "NotificationOptionInstallationFailed": "Falha na instalação", + "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", + "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio interrompida", + "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada", + "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada", + "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível", + "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.", + "NameSeasonUnknown": "Temporada desconhecida", "NameSeasonNumber": "Temporada {0}", "NameInstallFailed": "Falha na instalação de {0}", "MusicVideos": "Videoclipes", "Music": "Música", - "MixedContent": "Conteúdo diverso", - "Latest": "Mais Recente", + "MixedContent": "Conteúdo misto", + "Latest": "Mais recente", "LabelRunningTimeValue": "Duração: {0}", - "LabelIpAddressValue": "Endereço de IP: {0}", + "LabelIpAddressValue": "Endereço IP: {0}", "Inherit": "Herdar", - "HomeVideos": "Vídeos Caseiros", + "HomeVideos": "Vídeos caseiros", "Shows": "Séries", "Photos": "Fotografias", "Movies": "Filmes", - "FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}", + "FailedLoginAttemptWithUserName": "Tentativa falhada de início de sessão do utilizador {0}", "ChapterNameValue": "Capítulo {0}", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}", - "TaskCleanCache": "Limpar Diretório de Cache", + "TaskCleanCache": "Limpar pasta de cache", "TasksApplicationCategory": "Aplicação", - "TasksLibraryCategory": "Mediateca", + "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manutenção", - "TaskRefreshChannels": "Atualizar Canais", - "TaskUpdatePlugins": "Atualizar Plugins", - "TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.", - "TaskCleanLogs": "Limpar diretório de logs", - "TaskRefreshLibrary": "Analisar mediateca", + "TaskRefreshChannels": "Atualizar canais", + "TaskUpdatePlugins": "Atualizar plugins", + "TaskCleanLogsDescription": "Elimina ficheiros de registo com mais de {0} dias.", + "TaskCleanLogs": "Limpar pasta de registos", + "TaskRefreshLibrary": "Analisar biblioteca multimédia", "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", - "TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.", - "TasksChannelsCategory": "Canais de Internet", - "TaskRefreshChapterImages": "Extrair Imagens do Capítulo", - "TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.", + "TaskCleanCacheDescription": "Elimina ficheiros de cache que já não são necessários ao sistema.", + "TasksChannelsCategory": "Canais da Internet", + "TaskRefreshChapterImages": "Extrair imagens dos capítulos", + "TaskDownloadMissingSubtitlesDescription": "Procura na Internet legendas em falta com base na configuração dos metadados.", "TaskDownloadMissingSubtitles": "Transferir legendas em falta", - "TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.", - "TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.", - "TaskCleanTranscode": "Limpar o diretório de Transcode", - "TaskUpdatePluginsDescription": "Baixa e instala as atualizações para plug-ins configurados para atualização automática.", - "TaskRefreshPeopleDescription": "Atualizar metadados para elenco e equipa técnica da tua mediateca.", + "TaskRefreshChannelsDescription": "Atualiza as informações dos canais da Internet.", + "TaskCleanTranscodeDescription": "Elimina ficheiros de transcodificação com mais de um dia.", + "TaskCleanTranscode": "Limpar pasta de transcodificação", + "TaskUpdatePluginsDescription": "Transfere e instala atualizações dos plugins configurados para atualização automática.", + "TaskRefreshPeopleDescription": "Atualiza os metadados de atores e realizadores na tua biblioteca multimédia.", "TaskRefreshPeople": "Atualizar pessoas", - "TaskRefreshLibraryDescription": "Analisar a mediateca para novos ficheiros e atualizar os metadados.", - "TaskCleanActivityLog": "Limpar registro de atividade", + "TaskRefreshLibraryDescription": "Analisa a biblioteca multimédia à procura de novos ficheiros e atualiza os metadados.", + "TaskCleanActivityLog": "Limpar registo de atividade", "Undefined": "Indefinido", "Forced": "Forçado", "Default": "Predefinição", - "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.", + "TaskCleanActivityLogDescription": "Elimina as entradas do registo de atividade mais antigas do que o período configurado.", "TaskOptimizeDatabase": "Otimizar base de dados", - "TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", + "TaskOptimizeDatabaseDescription": "Compacta a base de dados e liberta espaço não utilizado. A execução desta tarefa depois de analisar a biblioteca ou de outras alterações que modifiquem a base de dados pode melhorar o desempenho.", "External": "Externo", - "HearingImpaired": "Problemas auditivos", - "TaskKeyframeExtractor": "Extrator de quadro-chave", - "TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.", + "HearingImpaired": "Deficiência auditiva", + "TaskKeyframeExtractor": "Extrator de fotogramas-chave", + "TaskKeyframeExtractorDescription": "Extrai fotogramas-chave de ficheiros de vídeo para criar playlists HLS mais precisas. Esta tarefa pode demorar algum tempo.", "TaskRefreshTrickplayImages": "Gerar imagens de trickplay", "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", "TaskAudioNormalization": "Normalização de áudio", - "TaskDownloadMissingLyrics": "Transferir letra em falta", - "TaskDownloadMissingLyricsDescription": "Transferir letra para músicas", - "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", - "TaskExtractMediaSegments": "Analisar segmentos de multimédia", - "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", - "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", - "CleanupUserDataTask": "Task de limpeza de dados do usuário", - "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.", + "TaskDownloadMissingLyrics": "Transferir letras em falta", + "TaskDownloadMissingLyricsDescription": "Transfere letras de músicas", + "TaskMoveTrickplayImagesDescription": "Move os ficheiros de trickplay existentes de acordo com as definições da biblioteca.", + "TaskExtractMediaSegments": "Analisar segmentos multimédia", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos multimédia de plugins com MediaSegment ativado.", + "TaskMoveTrickplayImages": "Migrar localização das imagens de trickplay", + "CleanupUserDataTask": "Limpeza de dados de utilizador", + "CleanupUserDataTaskDescription": "Remove todos os dados de utilizador (estado de reprodução, estado de favorito, etc.) de conteúdos multimédia que já não estejam presentes há pelo menos 90 dias.", "Original": "Original", - "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" + "LyricDownloadFailureFromForItem": "Falha ao transferir letras de {0} para {1}", + "NameExtraBehindTheScenes": "Bastidores", + "NameExtraClip": "Excerto", + "NameExtraDeletedScene": "Cena eliminada", + "NameExtraFeaturette": "Minidocumentário", + "NameExtraInterview": "Entrevista", + "NameExtraSample": "Amostra", + "NameExtraShort": "Curta-metragem", + "NameExtraThemeSong": "Tema musical", + "NameExtraThemeVideo": "Vídeo temático", + "NameExtraScene": "Cena", + "NameExtraUnknown": "Extra", + "NameExtraTrailer": "Trailer", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index ea83b88951..dccec8067d 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului", "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.", "LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "În culise", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scenă ștearsă", + "NameExtraFeaturette": "Material bonus", + "NameExtraInterview": "Interviu", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Monstră", + "NameExtraScene": "Scenă", + "NameExtraShort": "Scurt", + "NameExtraThemeSong": "Audio de Fundal", + "NameExtraThemeVideo": "Video de Fundal", + "NameExtraTrailer": "Trailer" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 40d5e3985d..c13fd3ebdc 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Задача очистки пользовательских данных", "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.", "Original": "Оригинальный", - "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}" + "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}", + "NameExtraBehindTheScenes": "За кадром", + "NameExtraClip": "Отрывок", + "NameExtraDeletedScene": "Удалённая сцена", + "NameExtraFeaturette": "Короткометражка", + "NameExtraInterview": "Интервью", + "NameExtraSample": "Образец", + "NameExtraScene": "Сцена", + "NameExtraThemeSong": "Заглавная песня", + "NameExtraThemeVideo": "Заглавное видео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Дополнительный материал", + "NameExtraNumbered": "{0} {1}", + "NameExtraShort": "Короткометражка" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 7ae8857e5d..a3ae6139ae 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -91,7 +91,7 @@ "Default": "Predvolené", "TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.", "TaskOptimizeDatabase": "Optimalizovať databázu", - "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.", + "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov. Táto úloha môže trvať dlhší čas.", "TaskKeyframeExtractor": "Extraktor kľúčových snímkov", "External": "Externé", "HearingImpaired": "Sluchovo postihnutí", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Prečistiť používateľské dáta", "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.", "LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}", - "Original": "Originál" + "Original": "Originál", + "NameExtraBehindTheScenes": "Zo zákulisia", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Vystrihnutá scéna", + "NameExtraFeaturette": "Bonus", + "NameExtraInterview": "Rozhovor", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Ukážka", + "NameExtraScene": "Scéna", + "NameExtraShort": "Krátky film", + "NameExtraThemeSong": "Úvodná pieseň", + "NameExtraThemeVideo": "Úvodné video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index a1b5b714af..6ea625d66c 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "Čiščenje uporabniških podatkov", "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.", "LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "V zakulisju", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Izbrisan prizor", + "NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Vzorec", + "NameExtraScene": "Prizor", + "NameExtraShort": "Kratki film", + "NameExtraThemeSong": "Tematska Pesem", + "NameExtraThemeVideo": "Tematski Video", + "NameExtraTrailer": "Napovednik" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index e13f7b09e1..11b25e9795 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.", "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve", "LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}", - "Original": "Origjinal" + "Original": "Origjinal", + "NameExtraBehindTheScenes": "Pamje nga prapaskenat", + "NameExtraClip": "Pjesë", + "NameExtraDeletedScene": "Skenë e fshirë", + "NameExtraFeaturette": "Film i shkurtër", + "NameExtraInterview": "Intervistë", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Shembull", + "NameExtraScene": "Skenë", + "NameExtraShort": "Film i shkurtër", + "NameExtraThemeSong": "Kënga e temës", + "NameExtraThemeVideo": "Videoja e temës", + "NameExtraTrailer": "Parapamje", + "NameExtraUnknown": "Shtesë" } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 92f309c80c..de0e425098 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -108,5 +108,18 @@ "TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање", "TaskDownloadMissingLyricsDescription": "Преузми стихове песама", "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}", - "Original": "Изворно" + "Original": "Изворно", + "NameExtraBehindTheScenes": "Иза кулиса", + "NameExtraClip": "Исечак", + "NameExtraDeletedScene": "Обрисана сцена", + "NameExtraFeaturette": "Кратки прилог", + "NameExtraInterview": "Интервју", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Узорак", + "NameExtraScene": "Сцена", + "NameExtraShort": "Кратки филм", + "NameExtraThemeSong": "Музичка тема", + "NameExtraThemeVideo": "Спот музичке теме", + "NameExtraTrailer": "Реклама", + "NameExtraUnknown": "Додатак" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 7384967122..7d741bca36 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -108,5 +108,18 @@ "CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.", "CleanupUserDataTask": "Uppgift för rensning av användardata", "Original": "Original", - "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}" + "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}", + "NameExtraBehindTheScenes": "Bakom kulisserna", + "NameExtraDeletedScene": "Borttagen scen", + "NameExtraInterview": "Intervju", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Scen", + "NameExtraShort": "Kortfilm", + "NameExtraThemeSong": "Signaturmelodi", + "NameExtraTrailer": "Trailer", + "NameExtraClip": "Klipp", + "NameExtraFeaturette": "Kortfilm", + "NameExtraSample": "Prov", + "NameExtraThemeVideo": "Signaturvideo", + "NameExtraUnknown": "Extra" } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index f613b973db..256f349926 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -106,5 +106,20 @@ "TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்", "TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.", "CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.", - "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி" + "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி", + "LyricDownloadFailureFromForItem": "{0} இலிருந்து {1} க்கு பாடல் வரிகளைப் பதிவிறக்க முடியவில்லை", + "NameExtraBehindTheScenes": "திரைக்குப் பின்னால்", + "NameExtraClip": "துண்டுக்காட்சி", + "NameExtraDeletedScene": "நீக்கப்பட்ட காட்சிகள்", + "NameExtraFeaturette": "திரைப்பின்னணித் தொகுப்பு", + "NameExtraInterview": "நேர்காணல்", + "NameExtraSample": "மாதிரி", + "NameExtraScene": "காட்சி", + "NameExtraShort": "குறுகிய", + "NameExtraThemeSong": "மையப் பாடல்", + "NameExtraThemeVideo": "மையக் காணொளி", + "NameExtraTrailer": "முன்னோட்டம்", + "NameExtraUnknown": "கூடுதல்", + "Original": "அசல்", + "NameExtraNumbered": "{0} {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 89c2c26748..77e526db74 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -22,20 +22,20 @@ "NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว", "NameSeasonUnknown": "ไม่ทราบซีซัน", "NameSeasonNumber": "ซีซัน {0}", - "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว", + "NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ", "MusicVideos": "มิวสิควิดีโอ", - "Music": "ดนตรี", + "Music": "เพลง", "Movies": "ภาพยนตร์", - "MixedContent": "เนื้อหาผสม", - "Latest": "ล่าสุด", - "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}", - "LabelIpAddressValue": "ที่อยู่ IP: {0}", - "Inherit": "สืบทอด", - "HomeVideos": "โฮมวิดีโอ", - "HeaderNextUp": "ถัดไป", - "HeaderLiveTV": "ทีวีสด", - "HeaderFavoriteShows": "รายการที่ชื่นชอบ", - "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ", + "MixedContent": "เนื้อหาหลากหลายประเภท", + "Latest": "มาใหม่ล่าสุด", + "LabelRunningTimeValue": "ความยาว: {0}", + "LabelIpAddressValue": "หมายเลข IP: {0}", + "Inherit": "ใช้ค่าเริ่มต้น", + "HomeVideos": "วิดีโอส่วนตัว", + "HeaderNextUp": "รายการถัดไป", + "HeaderLiveTV": "ทีวีถ่ายทอดสด", + "HeaderFavoriteShows": "รายการที่ชอบ", + "HeaderFavoriteEpisodes": "ตอนที่ชอบ", "HeaderContinueWatching": "ดูต่อ", "Genres": "ประเภท", "Folders": "โฟลเดอร์", @@ -106,5 +106,20 @@ "TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment", "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay", "CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้", - "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน" + "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน", + "LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ", + "Original": "ต้นฉบับ", + "NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ", + "NameExtraClip": "คลิปวิดีโอ", + "NameExtraDeletedScene": "ฉากที่ถูกตัดออก", + "NameExtraFeaturette": "คลิปสั้นพิเศษ", + "NameExtraInterview": "บทสัมภาษณ์", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "ตัวอย่าง", + "NameExtraScene": "ฉาก", + "NameExtraShort": "ภาพยนตร์สั้น", + "NameExtraThemeSong": "เพลงประกอบ", + "NameExtraThemeVideo": "วิดีโอธีม", + "NameExtraTrailer": "ตัวอย่างภาพยนตร์", + "NameExtraUnknown": "เนื้อหาพิเศษ" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 0c42d4a55f..87f49ae80d 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -1,5 +1,5 @@ { - "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}", + "AppDeviceValues": "Uygulama: {0}, Cihaz: {1}", "Artists": "Sanatçılar", "AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı", "Books": "Kitaplar", @@ -81,8 +81,8 @@ "TaskCleanLogs": "Günlük Dizinini Temizle", "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.", "TaskRefreshLibrary": "Medya Kütüphanesini Tara", - "TaskRefreshChapterImagesDescription": "Bölümlere ayrılmış videolar için küçük resimler oluştur.", - "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", + "TaskRefreshChapterImagesDescription": "Video bölümleri için küçük görseller oluştur.", + "TaskRefreshChapterImages": "Bölüm görsellerini çıkar", "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", "TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle", "TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Kullanıcı verisi temizleme görevi", "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler.", "LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi", - "Original": "Orijinal" + "Original": "Orijinal", + "NameExtraBehindTheScenes": "Kamera Arkası", + "NameExtraClip": "Klip", + "NameExtraDeletedScene": "Silinmiş Sahne", + "NameExtraFeaturette": "Kısa film", + "NameExtraInterview": "Röportaj", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Örnek", + "NameExtraScene": "Sahne", + "NameExtraShort": "Kısa", + "NameExtraThemeSong": "Tanıtım Müziği", + "NameExtraThemeVideo": "Tanıtım Videosu", + "NameExtraTrailer": "Fragman", + "NameExtraUnknown": "Fazladan" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index ccb9d915d1..856740545c 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "Завдання очищення даних користувача", "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.", "Original": "Оригінал", - "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}" + "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}", + "NameExtraBehindTheScenes": "За лаштунками", + "NameExtraClip": "Кліп", + "NameExtraDeletedScene": "Видалена сцена", + "NameExtraFeaturette": "Фічуретка", + "NameExtraInterview": "Інтерв’ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Приклад", + "NameExtraScene": "Сцена", + "NameExtraShort": "Коротко", + "NameExtraThemeSong": "Тематична пісня", + "NameExtraThemeVideo": "Тематичне вiдео", + "NameExtraTrailer": "Трейлер", + "NameExtraUnknown": "Додатково" } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index b3f24a31e3..a8ec31aed4 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -98,5 +98,28 @@ "TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں", "TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں", "TaskAudioNormalization": "آڈیو نارملائزیشن", - "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔" + "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔", + "LyricDownloadFailureFromForItem": "{1} کے لیے {0} سے دھن ڈاؤن لوڈ کرنے میں ناکامی", + "Original": "اصل", + "TaskRefreshTrickplayImages": "Trickplay تصاویر بنائیں", + "TaskRefreshTrickplayImagesDescription": "فعال لائبریریوں میں ویڈیوز کے لیے Trickplay پیش منظر بناتا ہے۔", + "TaskExtractMediaSegments": "میڈیا سیگمنٹ اسکین", + "TaskExtractMediaSegmentsDescription": "MediaSegment فعال پلگ انز سے میڈیا سیگمنٹس اخذ یا حاصل کرتا ہے۔", + "TaskMoveTrickplayImages": "Trickplay تصاویر کا مقام منتقل کریں", + "TaskMoveTrickplayImagesDescription": "لائبریری کی ترتیبات کے مطابق موجودہ Trickplay فائلیں منتقل کرتا ہے۔", + "CleanupUserDataTask": "صارف ڈیٹا صفائی کا ٹاسک", + "CleanupUserDataTaskDescription": "کم از کم 90 دن سے موجود نہ ہونے والے میڈیا سے تمام صارف ڈیٹا (دیکھنے کی حالت، پسندیدہ حیثیت وغیرہ) صاف کرتا ہے۔", + "NameExtraBehindTheScenes": "پسِ پردہ", + "NameExtraClip": "کلپ", + "NameExtraDeletedScene": "حذف شدہ منظر", + "NameExtraFeaturette": "فیچریٹ", + "NameExtraInterview": "انٹرویو", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "نمونہ", + "NameExtraScene": "منظر", + "NameExtraShort": "مختصر", + "NameExtraThemeSong": "تھیم سونگ", + "NameExtraThemeVideo": "تھیم ویڈیو", + "NameExtraTrailer": "ٹریلر", + "NameExtraUnknown": "اضافی" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 2ba665e2ff..77619b24d0 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -32,7 +32,7 @@ "TasksLibraryCategory": "Thư Viện", "TasksMaintenanceCategory": "Bảo Trì", "VersionNumber": "Phiên Bản {0}", - "UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}", + "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}", "UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}", "UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}", "UserOnlineFromDevice": "{0} trực tuyến từ {1}", @@ -79,7 +79,7 @@ "HeaderNextUp": "Tiếp Theo", "HeaderFavoriteShows": "Chương Trình Yêu Thích", "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", - "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}", + "FailedLoginAttemptWithUserName": "Cố gắng đăng nhập thất bại từ {0}", "ChapterNameValue": "Phân Cảnh {0}", "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", @@ -95,18 +95,31 @@ "TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.", "External": "Bên ngoài", "HearingImpaired": "Khiếm Thính", - "TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay", - "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.", + "TaskRefreshTrickplayImages": "Tạo Ảnh Tua Nhanh (Trickplay)", + "TaskRefreshTrickplayImagesDescription": "Tạo ảnh tua nhanh (trịckplay) xem thử cho các video trong các thư viện được kích hoạt.", "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh", "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.", "TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát", "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu", "TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.", - "TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay", - "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.", + "TaskMoveTrickplayImages": "Di Chuyển Vị Trí Ảnh Tua Nhanh (Trickplay)", + "TaskMoveTrickplayImagesDescription": "Di chuyển các tệp ảnh tua nhanh (trickplay) hiện có theo cài đặt thư viện.", "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện", "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng", "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.", "Original": "Gốc", - "LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}" + "LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}", + "NameExtraBehindTheScenes": "Hậu Trường", + "NameExtraDeletedScene": "Cảnh Bị Xóa", + "NameExtraInterview": "Phỏng vấn", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Mẫu", + "NameExtraScene": "Cảnh", + "NameExtraShort": "Ngắn", + "NameExtraThemeSong": "Bài Hát Chủ Đề", + "NameExtraThemeVideo": "Video Chủ Đề", + "NameExtraFeaturette": "Nội dung phụ", + "NameExtraClip": "Clip ngắn", + "NameExtraTrailer": "Đoạn giới thiệu", + "NameExtraUnknown": "Nội dung bổ sung" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 18418ae0bc..7c61ce6f17 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -22,7 +22,7 @@ "MixedContent": "混合内容", "Movies": "电影", "Music": "音乐", - "MusicVideos": "MV", + "MusicVideos": "音乐视频", "NameInstallFailed": "{0} 安装失败", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "用户数据清理任务", "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。", "LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词", - "Original": "原始" + "Original": "原始", + "NameExtraBehindTheScenes": "幕后花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "删减场景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采访", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "试看", + "NameExtraScene": "精选片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主题曲", + "NameExtraThemeVideo": "主题视频", + "NameExtraTrailer": "预告片", + "NameExtraUnknown": "额外内容" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index b2fcbc93a2..9557742d39 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -10,8 +10,8 @@ "Folders": "資料夾", "Genres": "風格", "HeaderContinueWatching": "繼續睇返", - "HeaderFavoriteEpisodes": "心水嘅劇集", - "HeaderFavoriteShows": "心水嘅節目", + "HeaderFavoriteEpisodes": "心水劇集", + "HeaderFavoriteShows": "心水節目", "HeaderLiveTV": "電視直播", "HeaderNextUp": "跟住落嚟", "HomeVideos": "家庭影片", @@ -108,5 +108,18 @@ "CleanupUserDataTask": "清理使用者資料嘅任務", "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。", "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞", - "Original": "原始" + "Original": "原始", + "NameExtraBehindTheScenes": "幕後花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "刪減場景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采訪", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "試看", + "NameExtraScene": "精選片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主題曲", + "NameExtraThemeVideo": "主題影片", + "NameExtraTrailer": "預告片", + "NameExtraUnknown": "額外內容" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 4e0dff87b9..bcc5b0b44f 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -108,5 +108,18 @@ "CleanupUserDataTask": "用戶資料清理工作", "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。", "Original": "原作", - "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞" + "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞", + "NameExtraBehindTheScenes": "幕後花絮", + "NameExtraClip": "片段", + "NameExtraDeletedScene": "刪減場景", + "NameExtraFeaturette": "花絮", + "NameExtraInterview": "采訪", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "試看", + "NameExtraScene": "精選片段", + "NameExtraShort": "短篇", + "NameExtraThemeSong": "主題曲", + "NameExtraThemeVideo": "主題影片", + "NameExtraTrailer": "預告片", + "NameExtraUnknown": "額外內容" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index e7dd984ec4..3f89237ab2 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -139,7 +139,8 @@ namespace Emby.Server.Implementations.Localization var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions).ConfigureAwait(false) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); - var dict = new Dictionary<string, ParentalRatingScore?>(); + // Rating strings are compared case insensitively, providers are not consistent about casing (e.g. "VM18" vs "vm18") + var dict = new Dictionary<string, ParentalRatingScore?>(StringComparer.OrdinalIgnoreCase); if (ratingSystem.Ratings is not null) { foreach (var ratingEntry in ratingSystem.Ratings) @@ -262,6 +263,24 @@ namespace Emby.Server.Implementations.Localization } /// <inheritdoc /> + public string? GetLanguageDisplayName(string language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var displayName = FindLanguageInfo(language)?.DisplayName; + if (displayName is null) + { + return null; + } + + // Truncate at the first delimiter to avoid cluttered display names + return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim(); + } + + /// <inheritdoc /> public IReadOnlyList<CountryInfo> GetCountries() { using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); @@ -356,12 +375,37 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(rating); + // Handle unrated content. This has to happen before the split below, + // because some of the unrated values contain a '/' themselves (e.g. "n/a"). + if (IsUnrated(rating)) + { + return null; + } + + // Several rating systems contain a '/' inside a single rating (e.g. "M/12" in PT, + // "U/A 13+" in IN, "7/i/fig" in ES), so the value as a whole always wins over the split below. + var wholeValueScore = GetSingleRatingScore(rating, countryCode); + if (wholeValueScore is not null) + { + return wholeValueScore; + } + // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år"). // Try each one in order and use the first that resolves. var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (ratingValues.Length == 1) + { + return null; + } foreach (var ratingValue in ratingValues) { + // A single entry of such a list may be unrated while a later one still resolves + if (IsUnrated(ratingValue)) + { + continue; + } + var score = GetSingleRatingScore(ratingValue, countryCode); if (score is not null) { @@ -373,16 +417,18 @@ namespace Emby.Server.Implementations.Localization } /// <summary> + /// Checks whether a rating value marks the content as unrated. + /// </summary> + /// <param name="rating">Rating value to check.</param> + /// <returns>Returns true if the value is an unrated marker.</returns> + private static bool IsUnrated(ReadOnlySpan<char> rating) + => _unratedValues.Contains(rating.Trim(), StringComparison.OrdinalIgnoreCase); + + /// <summary> /// Resolves a single rating value to a score. /// </summary> private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode) { - // Handle unrated content - if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return null; - } - // Convert ints directly // This may override some of the locale specific age ratings (but those always map to the same age) if (TryParseRatingAsScore(rating, out var ratingAge)) @@ -494,6 +540,12 @@ namespace Emby.Server.Implementations.Localization return true; } + // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure + if (IsUnrated(ratingPart)) + { + return true; + } + _logger.LogWarning( "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", rating, diff --git a/Emby.Server.Implementations/Localization/Ratings/it.json b/Emby.Server.Implementations/Localization/Ratings/it.json index f2889bf82c..c1d716a46c 100644 --- a/Emby.Server.Implementations/Localization/Ratings/it.json +++ b/Emby.Server.Implementations/Localization/Ratings/it.json @@ -3,28 +3,42 @@ "supportsSubScores": false, "ratings": [ { - "ratingStrings": ["T"], + "ratingStrings": ["T", "PT", "Per tutti"], "ratingScore": { "score": 0, "subScore": null } }, { - "ratingStrings": ["6+"], + "ratingStrings": ["6+", "VM6", "VM 6", "VM-6", "Vietato ai minori di 6 anni"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["14+"], + "ratingStrings": ["10+", "VM10", "VM 10", "VM-10", "Vietato ai minori di 10 anni"], + "ratingScore": { + "score": 10, + "subScore": null + } + }, + { + "ratingStrings": ["12+", "VM12", "VM 12", "VM-12", "Vietato ai minori di 12 anni"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["14+", "VM14", "VM 14", "VM-14", "Vietato ai minori di 14 anni"], "ratingScore": { "score": 14, "subScore": null } }, { - "ratingStrings": ["18+"], + "ratingStrings": ["18+", "VM18", "VM 18", "VM-18", "Vietato ai minori di 18 anni"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/no.json b/Emby.Server.Implementations/Localization/Ratings/no.json index a5e9523163..40c66e3956 100644 --- a/Emby.Server.Implementations/Localization/Ratings/no.json +++ b/Emby.Server.Implementations/Localization/Ratings/no.json @@ -10,49 +10,49 @@ } }, { - "ratingStrings": ["6"], + "ratingStrings": ["6", "6 år"], "ratingScore": { "score": 6, "subScore": null } }, { - "ratingStrings": ["7"], + "ratingStrings": ["7", "7 år"], "ratingScore": { "score": 7, "subScore": null } }, { - "ratingStrings": ["9"], + "ratingStrings": ["9", "9 år"], "ratingScore": { "score": 9, "subScore": null } }, { - "ratingStrings": ["11"], + "ratingStrings": ["11", "11 år"], "ratingScore": { "score": 11, "subScore": null } }, { - "ratingStrings": ["12"], + "ratingStrings": ["12", "12 år"], "ratingScore": { "score": 12, "subScore": null } }, { - "ratingStrings": ["15"], + "ratingStrings": ["15", "15 år"], "ratingScore": { "score": 15, "subScore": null } }, { - "ratingStrings": ["18"], + "ratingStrings": ["18", "18 år"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 409414139c..8208c85222 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists var playlist = _libraryManager.GetItemById(playlistId) as Playlist ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); - // Retrieve all the items to be added to the playlist + // Retrieve all the items to be added to the playlist. var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); - // Filter out duplicate items - var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); - newItems = newItems - .Where(i => !existingIds.Contains(i.Id)) - .Distinct(); - // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems .Select(LinkedChild.Create) .ToList(); - // Log duplicates that have been ignored, if any - int numDuplicates = newItemIds.Count - childrenToAdd.Count; - if (numDuplicates > 0) - { - _logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name); - } - // Do nothing else if there are no items to add to the playlist if (childrenToAdd.Count == 0) { @@ -384,7 +371,7 @@ namespace Emby.Server.Implementations.Playlists if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new WplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new WplPlaylistEntry() { @@ -417,7 +404,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new ZplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new ZplPlaylistEntry() { @@ -453,7 +440,7 @@ namespace Emby.Server.Implementations.Playlists { IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -485,7 +472,7 @@ namespace Emby.Server.Implementations.Playlists IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -513,7 +500,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase)) { var playlist = new PlsPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new PlsPlaylistEntry() { diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index f699c99d85..f7ad9b9498 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -255,6 +255,14 @@ namespace Emby.Server.Implementations.Plugins } _plugins.Add(plugin); + + // Updating a disabled plugin must not enable it again. + if (plugin.Manifest.Status == PluginStatus.Disabled) + { + ProcessAlternative(plugin); + return; + } + EnablePlugin(plugin); } @@ -387,29 +395,11 @@ namespace Emby.Server.Implementations.Plugins var url = new Uri(packageInfo.ImageUrl); imagePath = Path.Join(path, url.Segments[^1]); - var fileStream = AsyncFile.OpenWrite(imagePath); - Stream? downloadStream = null; - try - { - downloadStream = await HttpClientFactory - .CreateClient(NamedClient.Default) - .GetStreamAsync(url) - .ConfigureAwait(false); - - await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); - } - catch (HttpRequestException ex) + // The catalog is refreshed on every dashboard visit and rewrites the manifest of + // every installed plugin, so only fetch an image that is actually missing. + if (!ImageExists(imagePath)) { - _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); - imagePath = string.Empty; - } - finally - { - await fileStream.DisposeAsync().ConfigureAwait(false); - if (downloadStream is not null) - { - await downloadStream.DisposeAsync().ConfigureAwait(false); - } + imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false); } } @@ -448,6 +438,67 @@ namespace Emby.Server.Implementations.Plugins } } + private static bool ImageExists(string imagePath) + { + var image = new FileInfo(imagePath); + + // A previous download may have been interrupted, leaving an empty file behind. + return image.Exists && image.Length > 0; + } + + private async Task<string> DownloadImage(Uri url, string imagePath) + { + // Download to a temporary file and move it into place, so that neither a failed download + // nor a concurrent one can be observed as a partially written image. + var tempPath = imagePath + "." + Path.GetRandomFileName(); + + try + { + var fileStream = AsyncFile.Create(tempPath); + Stream? downloadStream = null; + try + { + downloadStream = await HttpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(url) + .ConfigureAwait(false); + + await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); + } + finally + { + await fileStream.DisposeAsync().ConfigureAwait(false); + if (downloadStream is not null) + { + await downloadStream.DisposeAsync().ConfigureAwait(false); + } + } + + File.Move(tempPath, imagePath, true); + + return imagePath; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException) + { + _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); + TryDeleteFile(tempPath); + + return string.Empty; + } + } + + private void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Unable to delete {Path}.", path); + } + } + /// <summary> /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. /// If no file is found, no reconciliation occurs. @@ -632,9 +683,10 @@ namespace Emby.Server.Implementations.Plugins return; } - var predecessor = _plugins.OrderByDescending(p => p.Version) - .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); - if (predecessor is not null) + var successor = _plugins.FirstOrDefault(p => p.Id.Equals(plugin.Id) + && p.Version > plugin.Version + && (p.IsEnabledAndSupported || p.Manifest.Status == PluginStatus.Disabled)); + if (successor is not null) { return; } @@ -763,6 +815,8 @@ namespace Emby.Server.Implementations.Plugins var entry = versions[x]; if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) { + lastName = string.Empty; + if (!TryGetPluginDlls(entry, out var allowedDlls)) { _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name); @@ -772,15 +826,18 @@ namespace Emby.Server.Implementations.Plugins entry.DllFiles = allowedDlls; + // Only clean up older versions when this version will actually be loaded. if (entry.IsEnabledAndSupported) { lastName = entry.Name; - continue; } + + continue; } if (string.IsNullOrEmpty(lastName)) { + // Unnamed plugin, so there is nothing to match older versions against. continue; } @@ -891,9 +948,9 @@ namespace Emby.Server.Implementations.Plugins if (previousVersion is null) { - // This value is memory only - so that the web will show restart required. - plugin.Manifest.Status = PluginStatus.Restart; - plugin.Manifest.AutoUpdate = false; + // Memory only, so that the web will show restart required. The manifest must keep + // holding the persisted state, or a later save would write the wrong state to disk. + plugin.RestartRequired = true; return; } @@ -906,9 +963,7 @@ namespace Emby.Server.Implementations.Plugins _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name); } - // This value is memory only - so that the web will show restart required. - plugin.Manifest.Status = PluginStatus.Restart; - plugin.Manifest.AutoUpdate = false; + plugin.RestartRequired = true; } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index e4939205c9..29b633530f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol) { t.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), + string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()), false, cancellationToken).ConfigureAwait(false); toSaveDbItems.Add(t); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 8d133dc074..687947616f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; /// <summary> -/// Optimizes Jellyfin's database by issuing a VACUUM command. +/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands. /// </summary> public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask { @@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask return; } - _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); + _logger.LogInformation("Vacuuming and analyzing jellyfin.db..."); try { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index dff9a473af..092a621bfc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Emby.Server.Implementations.Library.Validators; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; @@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; private readonly IFileSystem _fileSystem; private readonly ILogger<PeopleValidationTask> _logger; + private readonly ILogger<PeopleValidator> _validatorLogger; private readonly IItemTypeLookup _itemTypeLookup; /// <summary> @@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param> + /// <param name="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param> /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param> public PeopleValidationTask( ILibraryManager libraryManager, @@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask IDbContextFactory<JellyfinDbContext> dbContextFactory, IFileSystem fileSystem, ILogger<PeopleValidationTask> logger, + ILogger<PeopleValidator> validatorLogger, IItemTypeLookup itemTypeLookup) { _libraryManager = libraryManager; @@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask _dbContextFactory = dbContextFactory; _fileSystem = fileSystem; _logger = logger; + _validatorLogger = validatorLogger; _itemTypeLookup = itemTypeLookup; } @@ -109,6 +114,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var dupQuery = context.Peoples .GroupBy(e => new { e.Name, e.PersonType }) .Where(e => e.Count() > 1) + .OrderBy(e => e.Key.Name) + .ThenBy(e => e.Key.PersonType) .Select(e => e.Select(f => f.Id).ToArray()); var total = dupQuery.Count(); @@ -163,7 +170,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are // cleaned up above, so dead people are removed in a single pass instead of requiring a second run. IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33)); - await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false); + await new PeopleValidator(_libraryManager, _validatorLogger) + .Run(validateProgress, cancellationToken) + .ConfigureAwait(false); // Phase 3: Refresh images for people missing them (66-100%) IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66)); @@ -177,56 +186,51 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + List<Guid> peopleIds; + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - const int PartitionSize = 100; - - var numPeople = await context.BaseItems + // Read the candidates in one go rather than paging them. A refresh stamps the person and takes + // it out of this set, so a growing offset over a shrinking set walks past people it never visits. + peopleIds = await context.BaseItems .AsNoTracking() .Where(b => b.Type == personTypeName) .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) .Where(b => !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || string.IsNullOrEmpty(b.Overview)) - .CountAsync(cancellationToken) + .OrderBy(b => b.Id) + .Select(b => b.Id) + .ToListAsync(cancellationToken) .ConfigureAwait(false); + } - _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); + _logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count); - if (numPeople == 0) - { - progress.Report(100); - return; - } + if (peopleIds.Count == 0) + { + progress.Report(100); + return; + } - var numComplete = 0; - var numRefreshed = 0; + var numComplete = 0; + var numRefreshed = 0; - await foreach (var entry in context.BaseItems - .AsNoTracking() - .Where(b => b.Type == personTypeName) - .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) - .Where(b => - !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || - string.IsNullOrEmpty(b.Overview)) - .OrderBy(b => b.Id) - .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition)) - .PartitionEagerAsync(PartitionSize, cancellationToken) - .WithCancellation(cancellationToken) - .ConfigureAwait(false)) - { - if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false)) - { - numRefreshed++; - } + foreach (var personId in peopleIds) + { + cancellationToken.ThrowIfCancellationRequested(); - numComplete++; - progress.Report(100.0 * numComplete / numPeople); + if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false)) + { + numRefreshed++; } - _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); + numComplete++; + progress.Report(100.0 * numComplete / peopleIds.Count); } + + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); } private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken) @@ -243,8 +247,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, - MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh }; await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index 31153af20f..dd3da2214a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask { _logger.LogError(ex, "Error updating {Name}", package.Name); } + catch (TimeoutException ex) + { + // One slow download must not abort the updates for the remaining plugins. + _logger.LogError(ex, "Error downloading {Name}", package.Name); + } catch (InvalidDataException ex) { _logger.LogError(ex, "Error updating {Name}", package.Name); diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 828bdd6859..08e2578867 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session { if (!session.SessionControllers.Any(i => i.IsSessionActive)) { - var key = GetSessionKey(session.Client, session.DeviceId); + var key = GetSessionKey(session.Client, session.DeviceId, session.UserId); _activeConnections.TryRemove(key, out _); if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId)) @@ -369,7 +369,7 @@ namespace Emby.Server.Implementations.Session if (session is not null) { - var key = GetSessionKey(session.Client, session.DeviceId); + var key = GetSessionKey(session.Client, session.DeviceId, session.UserId); _activeConnections.TryRemove(key, out _); @@ -475,8 +475,11 @@ namespace Emby.Server.Implementations.Session } } - private static string GetSessionKey(string appName, string deviceId) - => appName + deviceId; + // The user is part of the key because the client name and the device id are taken from the + // request headers and are not bound to the access token. Without it, any authenticated user + // could claim another user's client/device pair and take over their session. + private static string GetSessionKey(string appName, string deviceId, Guid userId) + => appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture); /// <summary> /// Gets the connection. @@ -500,7 +503,7 @@ namespace Emby.Server.Implementations.Session ArgumentException.ThrowIfNullOrEmpty(deviceId); - var key = GetSessionKey(appName, deviceId); + var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty); SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user); SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession); if (ReferenceEquals(newSession, sessionInfo)) @@ -641,8 +644,7 @@ namespace Emby.Server.Implementations.Session if (playingSessions.Count > 0) { var idle = playingSessions - .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5) - .ToList(); + .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5); foreach (var session in idle) { @@ -656,7 +658,7 @@ namespace Emby.Server.Implementations.Session ItemId = session.NowPlayingItem is null ? Guid.Empty : session.NowPlayingItem.Id, SessionId = session.Id, MediaSourceId = session.PlayState?.MediaSourceId, - PositionTicks = session.PlayState?.PositionTicks + PositionTicks = session.LastPlaybackCheckInPositionTicks }).ConfigureAwait(false); } catch (Exception ex) @@ -1447,6 +1449,8 @@ namespace Emby.Server.Implementations.Session if (item is IItemByName byName) { + // A by-name item tags containers as well as leaves: a music genre tags its artists, + // and a by-name artist row is not a folder, so IsFolder does not exclude it here. return byName.GetTaggedItems(new InternalItemsQuery(user) { IsFolder = false, @@ -1461,7 +1465,7 @@ namespace Emby.Server.Implementations.Session }, IsVirtualItem = false, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } - }); + }).Where(i => i is not IItemByName); } if (item.IsFolder) @@ -1538,11 +1542,52 @@ namespace Emby.Server.Implementations.Session return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken); } - private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession) + private void AssertCanControl(SessionInfo session, SessionInfo controllingSession) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(controllingSession); + + var controllingUserId = controllingSession.UserId; + + // Controlling a session is always allowed when: + // - the caller has no associated user (an API key, which is a privileged context), + // - the target session is public (has no owning user), or + // - the caller's user is associated with the target session. + // Controlling a session owned by a different user requires the + // EnableRemoteControlOfOtherUsers permission. + if (controllingUserId.IsEmpty() + || session.UserId.IsEmpty() + || session.ContainsUser(controllingUserId)) + { + return; + } + + var controllingUser = _userManager.GetUserById(controllingUserId); + if (controllingUser is null + || !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) + { + throw new SecurityException("The current user does not have permission to remote control other users."); + } + } + + private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId) + { + var controllingUserId = controllingSession.UserId; + + // Playback reported by a session is also written to the user data of its additional users, + // so attaching anyone but the calling user requires administrative privileges. + if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId)) + { + return; + } + + var controllingUser = _userManager.GetUserById(controllingUserId); + if (controllingUser is null + || !controllingUser.HasPermission(PermissionKind.IsAdministrator)) + { + throw new SecurityException("The current user does not have permission to attach another user to a session."); + } } /// <summary> @@ -1560,16 +1605,24 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Adds the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception> /// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception> - public void AddAdditionalUser(string sessionId, Guid userId) + public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + var controllingSession = GetSession(controllingSessionId); + AssertCanControl(session, controllingSession); + AssertCanAttachUser(controllingSession, userId); + } + if (session.UserId.Equals(userId)) { throw new ArgumentException("The requested user is already the primary user of the session."); @@ -1577,7 +1630,8 @@ namespace Emby.Server.Implementations.Session if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId))) { - var user = _userManager.GetUserById(userId); + var user = _userManager.GetUserById(userId) + ?? throw new ArgumentException("The requested user does not exist."); var newUser = new SessionUserInfo { UserId = userId, @@ -1591,16 +1645,22 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Removes the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception> /// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception> - public void RemoveAdditionalUser(string sessionId, Guid userId) + public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + if (session.UserId.Equals(userId)) { throw new ArgumentException("The requested user is already the primary user of the session."); @@ -1804,14 +1864,21 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Reports the capabilities. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="capabilities">The capabilities.</param> - public void ReportCapabilities(string sessionId, ClientCapabilities capabilities) + /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception> + public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities) { CheckDisposed(); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + ReportCapabilities(session, capabilities, true); } @@ -1906,13 +1973,18 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public void ReportNowViewingItem(string sessionId, string itemId) + public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId) { ArgumentException.ThrowIfNullOrEmpty(itemId); var item = _libraryManager.GetItemById(new Guid(itemId)); var session = GetSession(sessionId); + if (!string.IsNullOrEmpty(controllingSessionId)) + { + AssertCanControl(session, GetSession(controllingSessionId)); + } + session.NowViewingItem = GetItemInfo(item, null); } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 2582ed9df0..2100c23c45 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -171,7 +171,7 @@ namespace Emby.Server.Implementations.Session { await SendForceKeepAlive(webSocket).ConfigureAwait(false); } - catch (WebSocketException exception) + catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException) { _logger.LogWarning(exception, "Cannot send ForceKeepAlive message to WebSocket {0}.", webSocket); } @@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session if (inactive.Count > 0) { - _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); + _logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); } foreach (var webSocket in inactive) @@ -232,7 +232,7 @@ namespace Emby.Server.Implementations.Session { await SendForceKeepAlive(webSocket).ConfigureAwait(false); } - catch (WebSocketException exception) + catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException) { _logger.LogInformation(exception, "Error sending ForceKeepAlive message to WebSocket."); lost.Add(webSocket); diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index f10e7fcbb7..4159f8cf7d 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -3,35 +3,15 @@ using System; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { - public class DateLastMediaAddedComparer : IUserBaseItemComparer + public class DateLastMediaAddedComparer : IBaseItemComparer { /// <summary> - /// Gets or sets the user. - /// </summary> - /// <value>The user.</value> - public User User { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } - - /// <summary> - /// Gets or sets the user data manager. - /// </summary> - /// <value>The user data manager.</value> - public IUserDataManager UserDataManager { get; set; } - - /// <summary> /// Gets the name. /// </summary> /// <value>The name.</value> diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs index 8c8b8824f3..30b268bb60 100644 --- a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs @@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Sorting return x.PremiereDate.Value; } - if (x.ProductionYear.HasValue) + if (x.ProductionYear is not null) { try { diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs index 9aec87f183..8774bd8d4f 100644 --- a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs +++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs @@ -39,7 +39,7 @@ namespace Emby.Server.Implementations.Sorting return 0; } - if (x.ProductionYear.HasValue) + if (x.ProductionYear is not null) { return x.ProductionYear.Value; } diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index 38a0018a70..6fbe46ffd6 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -11,6 +11,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; using MediaBrowser.Controller.SyncPlay.Queue; using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; @@ -27,6 +28,11 @@ namespace Emby.Server.Implementations.SyncPlay public class Group : IGroupStateContext { /// <summary> + /// The default value of <see cref="GroupWaitTimeout"/>, in milliseconds. + /// </summary> + internal const long DefaultGroupWaitTimeout = 30000; + + /// <summary> /// The logger. /// </summary> private readonly ILogger<Group> _logger; @@ -54,8 +60,12 @@ namespace Emby.Server.Implementations.SyncPlay /// <summary> /// The participants, or members of the group. /// </summary> - private readonly Dictionary<string, GroupMember> _participants = - new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, GroupMember> _participants = new(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// The sessions of the participants, which only carry identifiers. + /// </summary> + private readonly Dictionary<string, SessionInfo> _participantSessions = new(StringComparer.OrdinalIgnoreCase); /// <summary> /// The internal group state. @@ -91,6 +101,18 @@ namespace Emby.Server.Implementations.SyncPlay public long DefaultPing { get; } = 500; /// <summary> + /// Gets the maximum ping, in milliseconds, accepted from a session. + /// </summary> + /// <remarks> + /// Pings are reported by clients and are scaled into the delays used to schedule playback, + /// so an unbounded value lets a single session push the whole group's resume point + /// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a + /// usable measurement for synchronisation. + /// </remarks> + /// <value>The maximum ping.</value> + public long MaxPing { get; } = 10000; + + /// <summary> /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. /// </summary> /// <value>The maximum time offset error.</value> @@ -103,6 +125,19 @@ namespace Emby.Server.Implementations.SyncPlay public long MaxPlaybackOffset { get; } = 500; /// <summary> + /// Gets the maximum time, in milliseconds, the group waits for its members to report ready. + /// </summary> + /// <value>The group-wait timeout.</value> + internal long GroupWaitTimeout { get; init; } = DefaultGroupWaitTimeout; + + /// <summary> + /// Gets the <see cref="Environment.TickCount64"/> value at which the group gives up waiting + /// for its members, or <c>null</c> when it is not waiting for anyone. + /// </summary> + /// <value>The group-wait deadline.</value> + internal long? GroupWaitDeadline { get; private set; } + + /// <summary> /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> @@ -151,6 +186,8 @@ namespace Emby.Server.Implementations.SyncPlay Ping = DefaultPing, IsBuffering = false }); + + _participantSessions[session.Id] = session; } /// <summary> @@ -160,6 +197,8 @@ namespace Emby.Server.Implementations.SyncPlay private void RemoveSession(SessionInfo session) { _participants.Remove(session.Id); + _participantSessions.Remove(session.Id); + UpdateGroupWaitDeadline(false); } /// <summary> @@ -377,13 +416,20 @@ namespace Emby.Server.Implementations.SyncPlay { value.IgnoreGroupWait = ignoreGroupWait; } + + UpdateGroupWaitDeadline(false); } /// <inheritdoc /> public void SetState(IGroupState state) { _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type); - this._state = state; + _state = state; + + if (state.Type != GroupStateType.Waiting) + { + GroupWaitDeadline = null; + } } /// <inheritdoc /> @@ -438,7 +484,7 @@ namespace Emby.Server.Implementations.SyncPlay { if (_participants.TryGetValue(session.Id, out GroupMember value)) { - value.Ping = ping; + value.Ping = Math.Clamp(ping, 0, MaxPing); } } @@ -451,7 +497,9 @@ namespace Emby.Server.Implementations.SyncPlay max = Math.Max(max, session.Ping); } - return max; + // A group with no participants has no ping to report. Returning long.MinValue would + // overflow the callers that scale this value into ticks, so fall back to the default. + return max == long.MinValue ? DefaultPing : max; } /// <inheritdoc /> @@ -461,6 +509,8 @@ namespace Emby.Server.Implementations.SyncPlay { value.IsBuffering = isBuffering; } + + UpdateGroupWaitDeadline(false); } /// <inheritdoc /> @@ -470,6 +520,9 @@ namespace Emby.Server.Implementations.SyncPlay { session.IsBuffering = isBuffering; } + + // Resetting the status of every session starts a new waiting period. + UpdateGroupWaitDeadline(isBuffering); } /// <inheritdoc /> @@ -676,5 +729,85 @@ namespace Emby.Server.Implementations.SyncPlay PlayQueue.ShuffleMode, PlayQueue.RepeatMode); } + + /// <summary> + /// Stops waiting for the members that have not reported ready and lets the rest of the + /// group carry on. Does nothing until <see cref="GroupWaitDeadline"/> has passed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + internal void HandleGroupWaitTimeout(CancellationToken cancellationToken) + { + var deadline = GroupWaitDeadline; + if (deadline is null || deadline > Environment.TickCount64) + { + return; + } + + GroupWaitDeadline = null; + + if (_state is not WaitingGroupState waitingState) + { + return; + } + + var blockingSessions = _participantSessions + .Values + .Where(participant => _participants.TryGetValue(participant.Id, out var member) + && member.IsBuffering + && !member.IgnoreGroupWait) + .ToList(); + + if (blockingSessions.Count == 0) + { + return; + } + + // The recovery below is broadcast to the whole group, so it does not matter which of + // the sessions that kept the group waiting is the one acting on the group's behalf. + var session = blockingSessions[0]; + + _logger.LogWarning( + "Group {GroupId} waited {Waited} ms for session(s) {SessionIds} to report ready, giving up.", + GroupId.ToString(), + GroupWaitTimeout + Environment.TickCount64 - deadline.Value, + string.Join(", ", blockingSessions.Select(participant => participant.Id))); + + if (waitingState.ResumePlaying) + { + // An unpause request in the waiting state means "start now, ignoring the sessions + // that are not ready". + var unpauseRequest = new UnpauseGroupRequest(); + waitingState.HandleRequest(unpauseRequest, this, GroupStateType.Waiting, session, cancellationToken); + return; + } + + // The members have been paused for the whole waiting period, so the playback position + // stays where the wait started. + SetAllBuffering(false); + SetState(new PausedGroupState(_loggerFactory)); + + var command = NewSyncPlayCommand(SendCommandType.Pause); + SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + var stateUpdate = new GroupStateUpdate(GroupStateType.Paused, PlaybackRequestType.Pause); + var update = new SyncPlayStateUpdate(GroupId, stateUpdate); + SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + private void UpdateGroupWaitDeadline(bool startNewWaitingPeriod) + { + if (_state.Type != GroupStateType.Waiting || !IsBuffering()) + { + GroupWaitDeadline = null; + return; + } + + // A running deadline covers the waiting period as a whole, so the sessions that keep + // reporting buffering while they load must not push it back. + if (GroupWaitDeadline is null || startNewWaitingPeriod) + { + GroupWaitDeadline = Environment.TickCount64 + GroupWaitTimeout; + } + } } } diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index b45d754554..88dfb070b8 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -19,6 +19,11 @@ namespace Emby.Server.Implementations.SyncPlay public class SyncPlayManager : ISyncPlayManager, IDisposable { /// <summary> + /// How often, in milliseconds, the groups are checked for a spent wait deadline. + /// </summary> + private const int GroupWaitSweepInterval = 1000; + + /// <summary> /// The logger. /// </summary> private readonly ILogger<SyncPlayManager> _logger; @@ -69,6 +74,11 @@ namespace Emby.Server.Implementations.SyncPlay /// </remarks> private readonly Lock _groupsLock = new(); + /// <summary> + /// The timer that watches the groups' wait deadlines, running only while there are groups. + /// </summary> + private readonly Timer _groupWaitTimer; + private bool _disposed = false; /// <summary> @@ -90,8 +100,15 @@ namespace Emby.Server.Implementations.SyncPlay _libraryManager = libraryManager; _logger = loggerFactory.CreateLogger<SyncPlayManager>(); _sessionManager.SessionEnded += OnSessionEnded; + _groupWaitTimer = new Timer(_ => OnGroupWaitTimerTick(), null, Timeout.Infinite, Timeout.Infinite); } + /// <summary> + /// Gets the maximum time, in milliseconds, a group waits for its members to report ready. + /// </summary> + /// <value>The group-wait timeout.</value> + internal long GroupWaitTimeout { get; init; } = Group.DefaultGroupWaitTimeout; + /// <inheritdoc /> public void Dispose() { @@ -122,8 +139,12 @@ namespace Emby.Server.Implementations.SyncPlay LeaveGroup(session, leaveGroupRequest, cancellationToken); } - var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager); + var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager) + { + GroupWaitTimeout = GroupWaitTimeout + }; _groups[group.GroupId] = group; + UpdateGroupWaitTimer(); if (!_sessionToGroupMap.TryAdd(session.Id, group)) { @@ -181,8 +202,8 @@ namespace Emby.Server.Implementations.SyncPlay { if (existingGroup.GroupId.Equals(request.GroupId)) { - // Restore session. - UpdateSessionsCounter(session.UserId, 1); + // Restore session. The session is already in the group and has already + // been counted, so the counter must not be incremented a second time. group.SessionJoin(session, request, cancellationToken); return; } @@ -242,6 +263,7 @@ namespace Emby.Server.Implementations.SyncPlay { _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); _groups.Remove(group.GroupId, out _); + UpdateGroupWaitTimer(); } } } @@ -332,8 +354,11 @@ namespace Emby.Server.Implementations.SyncPlay // Group lock required as Group is not thread-safe. lock (group) { - // Make sure that session still belongs to this group. - if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) + // Make sure that session still belongs to this group. The lookup can fail + // outright when the session left while this request was waiting on the group + // lock, which is exactly the case this re-check exists to catch. + if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) + || !checkGroup.GroupId.Equals(group.GroupId)) { // Drop request. return; @@ -381,7 +406,50 @@ namespace Emby.Server.Implementations.SyncPlay } _sessionManager.SessionEnded -= OnSessionEnded; - _disposed = true; + + lock (_groupsLock) + { + _disposed = true; + _groupWaitTimer.Dispose(); + } + } + + private void UpdateGroupWaitTimer() + { + if (_disposed) + { + return; + } + + var interval = _groups.IsEmpty ? Timeout.Infinite : GroupWaitSweepInterval; + _groupWaitTimer.Change(interval, interval); + } + + private void OnGroupWaitTimerTick() + { + try + { + lock (_groupsLock) + { + if (_disposed) + { + return; + } + + foreach (var (_, group) in _groups) + { + // Group lock required as Group is not thread-safe. + lock (group) + { + group.HandleGroupWaitTimeout(CancellationToken.None); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while recovering SyncPlay groups from a timed out wait."); + } } private void OnSessionEnded(object sender, SessionEventArgs e) @@ -400,7 +468,7 @@ namespace Emby.Server.Implementations.SyncPlay // Update sessions counter. var newSessionsCounter = _activeUsers.AddOrUpdate( userId, - 1, + toAdd, (_, sessionsCounter) => sessionsCounter + toAdd); // Should never happen. diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 6a60f7f5f6..cccdb3e6aa 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -11,7 +11,6 @@ using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; @@ -34,6 +33,9 @@ namespace Emby.Server.Implementations.Updates public class InstallationManager : IInstallationManager { private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']); + // Budget for the whole package download. The response headers are already bounded by the + // HttpClient timeout; this covers reading the package body, which can be large and slow. + private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10); /// <summary> /// The logger. @@ -82,8 +84,8 @@ namespace Emby.Server.Implementations.Updates IServerConfigurationManager config, IPluginManager pluginManager) { - _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); - _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); + _currentInstallations = []; + _completedInstallationsInternal = []; _logger = logger; _applicationHost = appHost; @@ -341,8 +343,9 @@ namespace Emby.Server.Implementations.Updates _applicationHost.NotifyPendingRestart(); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (linkedToken.IsCancellationRequested) { + // Only an actually cancelled token is a cancellation. lock (_currentInstallationsLock) { _currentInstallations.Remove(tuple); @@ -356,7 +359,7 @@ namespace Emby.Server.Implementations.Updates } catch (Exception ex) { - _logger.LogError(ex, "Package installation failed"); + _logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version); lock (_currentInstallationsLock) { @@ -500,8 +503,9 @@ namespace Emby.Server.Implementations.Updates var plugins = _pluginManager.Plugins; foreach (var plugin in plugins) { - // Don't auto update when plugin marked not to, or when it's disabled. - if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled) + // Don't auto update when plugin marked not to, or when it's disabled or pending removal. + if (plugin.Manifest?.AutoUpdate == false + || plugin.Manifest?.Status is PluginStatus.Disabled or PluginStatus.Deleted) { continue; } @@ -545,12 +549,36 @@ namespace Emby.Server.Implementations.Updates throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); } - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + // ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers + // the whole download; the package gets the longer budget below instead. + using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + downloadTokenSource.CancelAfter(PackageDownloadTimeout); + var downloadToken = downloadTokenSource.Token; + + var buffer = new MemoryStream(); + await using (buffer.ConfigureAwait(false)) { + try + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // The package is read twice, for the checksum and for the extraction, so it has + // to be buffered: the response stream is not seekable. + await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // Either our budget above or the HttpClient timeout ran out. + throw new TimeoutException( + $"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.", + ex); + } + + buffer.Position = 0; + Stream stream = buffer; + // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 cancellationToken.ThrowIfCancellationRequested(); diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs index 7efb5b1698..76874d10de 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SyncPlay; @@ -34,6 +35,13 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement) { var userId = context.User.GetUserId(); + if (userId.IsEmpty()) + { + // Unauthenticated requests and API keys carry no user, so there is nothing to + // check: leave the requirement unsatisfied and let the request be challenged. + return Task.CompletedTask; + } + var user = _userManager.GetUserById(userId); if (user is null) { diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index d6cc0e71a4..8e17dd7d53 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; using Jellyfin.Data.Queries; using Jellyfin.Database.Implementations.Enums; @@ -84,36 +84,9 @@ public class ActivityLogController : BaseJellyfinApiController ItemId = itemId, Username = username, Severity = severity, - OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []), + OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []), }; return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false); } - - private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy( - IReadOnlyList<ActivityLogSortBy> sortBy, - IReadOnlyList<SortOrder> requestedSortOrder) - { - if (sortBy.Count == 0) - { - return []; - } - - var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count]; - var i = 0; - for (; i < requestedSortOrder.Count; i++) - { - result[i] = (sortBy[i], requestedSortOrder[i]); - } - - // Add remaining elements with the first specified SortOrder - // or the default one if no SortOrders are specified - var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; - for (; i < sortBy.Count; i++) - { - result[i] = (sortBy[i], order); - } - - return result; - } } diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index f19ca77818..fdbbace1e7 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -126,6 +126,12 @@ public class ArtistsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); @@ -193,31 +199,7 @@ public class ArtistsController : BaseJellyfinApiController var result = _libraryManager.GetArtists(query); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> @@ -298,6 +280,12 @@ public class ArtistsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = null; BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); @@ -365,31 +353,7 @@ public class ArtistsController : BaseJellyfinApiController var result = _libraryManager.GetAlbumArtists(query); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 838f48949d..034a9dea55 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -1456,22 +1455,16 @@ public class DynamicHlsController : BaseJellyfinApiController var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - TranscodingJob? job; - - if (System.IO.File.Exists(segmentPath)) - { - job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); - } - + // Keep segment selection and transcoding replacement under the same playlist lock. + // An out-of-order request must not replace a job while another request is using its output. using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { + TranscodingJob? job; var startTranscoding = false; if (System.IO.File.Exists(segmentPath)) { job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); + _logger.LogDebug("returning {0} [it exists]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } @@ -1505,6 +1498,9 @@ public class DynamicHlsController : BaseJellyfinApiController // If the playlist doesn't already exist, startup ffmpeg try { + var currentJob = _transcodeManager.GetTranscodingJob(playlistPath, TranscodingJobType); + await WaitForActiveTranscodingRequests(currentJob, cancellationToken).ConfigureAwait(false); + await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) .ConfigureAwait(false); @@ -1540,11 +1536,19 @@ public class DynamicHlsController : BaseJellyfinApiController await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); } } + + _logger.LogDebug("returning {0} [general case]", segmentPath); + job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } + } - _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken) + { + while (job?.ActiveRequestCount > 0) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } } private static double[] GetSegmentLengths(StreamState state) @@ -1607,8 +1611,9 @@ public class DynamicHlsController : BaseJellyfinApiController if (state.VideoStream is not null && state.IsOutputVideo) { - // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT - hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + // fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + // HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx"; } segmentFormat = "fmp4" + outputFmp4HeaderArg; @@ -1646,9 +1651,9 @@ public class DynamicHlsController : BaseJellyfinApiController segmentFormat, startNumber.ToString(CultureInfo.InvariantCulture), baseUrlParam, - EncodingUtils.NormalizePath(outputTsArg), + outputTsArg.EscapeProcessArgument(), hlsArguments, - EncodingUtils.NormalizePath(outputPath)).Trim(); + outputPath.EscapeProcessArgument()).Trim(); } /// <summary> diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index cfc8be28ae..b9ca45cc1c 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -158,13 +158,62 @@ public class FilterController : BaseJellyfinApiController IsSeries = isSeries }; + var streamLanguageQuery = new InternalItemsQuery(user) + { + // It's possible that different langauges are only available on alternative versions. + // To fetch them all, owned items are included. + IncludeOwnedItems = true, + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + + var tagQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) { - genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + genreQuery.AncestorIds = ancestorIds; + streamLanguageQuery.AncestorIds = ancestorIds; + tagQuery.AncestorIds = ancestorIds; } else { genreQuery.Parent = parentItem; + streamLanguageQuery.Parent = parentItem; + tagQuery.Parent = parentItem; + } + + if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season)) + && !includeItemTypes.Contains(BaseItemKind.Episode)) + { + // streams are joined on epsiodes not shows or seasons + streamLanguageQuery.IncludeItemTypes = [.. includeItemTypes, BaseItemKind.Episode]; } if (includeItemTypes.Length == 1 @@ -188,10 +237,15 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } - if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series)) + filters.Tags = _libraryManager.GetTagNames(tagQuery); + + if (includeItemTypes.Contains(BaseItemKind.Movie) + || includeItemTypes.Contains(BaseItemKind.Series) + || includeItemTypes.Contains(BaseItemKind.Season) + || includeItemTypes.Contains(BaseItemKind.Episode)) { filters.AudioLanguages = _libraryManager - .GetMediaStreamLanguages(MediaStreamType.Audio) + .GetMediaStreamLanguages(MediaStreamType.Audio, streamLanguageQuery) .Select(language => { var culture = _localization.FindLanguageInfo(language); @@ -204,7 +258,7 @@ public class FilterController : BaseJellyfinApiController .OrderBy(l => l.Name) .ToArray(); filters.SubtitleLanguages = _libraryManager - .GetMediaStreamLanguages(MediaStreamType.Subtitle) + .GetMediaStreamLanguages(MediaStreamType.Subtitle, streamLanguageQuery) .Select(language => { var culture = _localization.FindLanguageInfo(language); diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 39c3f5abcf..18a8b67be2 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -97,6 +97,12 @@ public class GenresController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -143,8 +149,7 @@ public class GenresController : BaseJellyfinApiController result = _libraryManager.GetGenres(query); } - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 52d8b4dad1..d492a2f5ba 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath; + var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username); + if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath)) + { + return BadRequest("Invalid user."); + } + if (user.ProfileImage is not null) { await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index d009f80a96..39ba5ab186 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; @@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController searchResult.ProviderIds); // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = searchResult.ProviderIds; + item.SetProviderIds(searchResult.ProviderIds); await _providerManager.RefreshFullItem( item, new MetadataRefreshOptions(new DirectoryService(_fileSystem)) diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index e26264d292..65fffc4181 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -236,7 +236,7 @@ public class ItemUpdateController : BaseJellyfinApiController return NoContent(); } - private async Task UpdateItem(BaseItemDto request, BaseItem item) + internal async Task UpdateItem(BaseItemDto request, BaseItem item) { item.Name = request.Name; item.ForcedSortName = request.ForcedSortName; @@ -250,7 +250,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.IndexNumber = request.IndexNumber; item.ParentIndexNumber = request.ParentIndexNumber; item.Overview = request.Overview; - item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + if (request.Genres is not null) + { + item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } if (item is Episode episode) { @@ -293,10 +297,20 @@ public class ItemUpdateController : BaseJellyfinApiController item.CustomRating = request.CustomRating; var currentTags = item.Tags; - var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - var removedTags = currentTags.Except(newTags).ToList(); - var addedTags = newTags.Except(currentTags).ToList(); - item.Tags = newTags; + List<string> removedTags; + List<string> addedTags; + if (request.Tags is not null) + { + var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + removedTags = currentTags.Except(newTags).ToList(); + addedTags = newTags.Except(currentTags).ToList(); + item.Tags = newTags; + } + else + { + removedTags = []; + addedTags = []; + } if (item is Series rseries) { @@ -412,16 +426,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.RunTimeTicks = request.RunTimeTicks; } - foreach (var pair in request.ProviderIds.ToList()) + if (request.ProviderIds is not null) { - if (string.IsNullOrEmpty(pair.Value)) - { - request.ProviderIds.Remove(pair.Key); - } + item.SetProviderIds(request.ProviderIds); } - item.ProviderIds = request.ProviderIds; - if (item is Video video) { video.Video3DFormat = request.Video3DFormat; diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index e46795554b..7f63e410a5 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -14,7 +14,9 @@ using MediaBrowser.Common.Api; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; @@ -33,6 +35,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. @@ -40,14 +43,17 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param> public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) + ILibraryMonitor libraryMonitor, + IDirectoryService directoryService) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; + _directoryService = directoryService; } /// <summary> @@ -122,12 +128,14 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="newName">The new name.</param> /// <param name="refreshLibrary">Whether to refresh the library.</param> /// <response code="204">Folder renamed.</response> + /// <response code="400">The new name is not a valid library name.</response> /// <response code="404">Library doesn't exist.</response> /// <response code="409">Library already exists.</response> - /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> + /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> /// <exception cref="ArgumentNullException">The new name may not be null.</exception> [HttpPost("Name")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] public ActionResult RenameVirtualFolder( @@ -147,10 +155,15 @@ public class LibraryStructureController : BaseJellyfinApiController var rootFolderPath = _appPaths.DefaultUserViewsPath; - var currentPath = Path.Combine(rootFolderPath, name); - var newPath = Path.Combine(rootFolderPath, newName); + // Both names are caller supplied, so they have to be confined to the libraries root. + var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName); + if (newPath is null) + { + return BadRequest("The new name is not a valid library name."); + } - if (!Directory.Exists(currentPath)) + var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name); + if (currentPath is null || !Directory.Exists(currentPath)) { return NotFound("The media collection does not exist."); } @@ -170,11 +183,11 @@ public class LibraryStructureController : BaseJellyfinApiController var tempPath = Path.Combine( rootFolderPath, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - Directory.Move(currentPath, tempPath); + _directoryService.Move(currentPath, tempPath); currentPath = tempPath; } - Directory.Move(currentPath, newPath); + _directoryService.Move(currentPath, newPath); } finally { @@ -200,7 +213,7 @@ public class LibraryStructureController : BaseJellyfinApiController { _libraryManager.ClearIgnoreRuleCache(); // We don't know if this one can be validated individually, trigger a new validation - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } _libraryManager.ClearIgnoreRuleCache(); @@ -247,7 +260,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { @@ -314,7 +327,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } else { diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index ac7c091f85..aa942e7642 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -84,7 +84,7 @@ public class MediaInfoController : BaseJellyfinApiController return NotFound(); } - return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false); + return await _mediaInfoHelper.GetPlaybackInfo(item, user, Request).ConfigureAwait(false); } /// <summary> @@ -177,6 +177,7 @@ public class MediaInfoController : BaseJellyfinApiController var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId, liveStreamId) .ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 7af44f8bd6..4ebf914895 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -98,6 +98,12 @@ public class MusicGenresController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -134,8 +140,7 @@ public class MusicGenresController : BaseJellyfinApiController var result = _libraryManager.GetMusicGenres(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 1f8f963f70..10bcf4e717 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Api; +using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Updates; @@ -23,16 +25,22 @@ public class PackageController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IPluginManager _pluginManager; /// <summary> /// Initializes a new instance of the <see cref="PackageController"/> class. /// </summary> /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) + /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> + public PackageController( + IInstallationManager installationManager, + IServerConfigurationManager serverConfigurationManager, + IPluginManager pluginManager) { _installationManager = installationManager; _serverConfigurationManager = serverConfigurationManager; + _pluginManager = pluginManager; } /// <summary> @@ -48,6 +56,13 @@ public class PackageController : BaseJellyfinApiController [FromRoute, Required] string name, [FromQuery] Guid? assemblyGuid) { + // Plugins bundled with the server are not published to any repository, so querying + // the configured repositories for them can only ever fail, and does so slowly. + if (IsBundledPlugin(name, assemblyGuid)) + { + return NotFound(); + } + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var result = _installationManager.FilterPackages( packages, @@ -96,6 +111,11 @@ public class PackageController : BaseJellyfinApiController [FromQuery] string? version, [FromQuery] string? repositoryUrl) { + if (IsBundledPlugin(name, assemblyGuid)) + { + return NotFound(); + } + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); if (!string.IsNullOrEmpty(repositoryUrl)) { @@ -161,4 +181,13 @@ public class PackageController : BaseJellyfinApiController _serverConfigurationManager.SaveConfiguration(); return NoContent(); } + + private bool IsBundledPlugin(string name, Guid? assemblyGuid) + { + var plugin = assemblyGuid is Guid id && !id.IsEmpty() + ? _pluginManager.GetPlugin(id) + : _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + return plugin?.Instance?.CanUninstall == false; + } } diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 9ffccaa9e9..51d4081ecf 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -4,6 +4,7 @@ using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; @@ -103,6 +104,7 @@ public class PersonsController : BaseJellyfinApiController personTypes, excludePersonTypes) { + AccessFilter = BuildAccessFilter(user), NameContains = searchTerm, NameStartsWith = nameStartsWith, NameLessThan = nameLessThan, @@ -123,6 +125,20 @@ public class PersonsController : BaseJellyfinApiController .ToArray()); } + // People are not owned by a library, so nothing in the Peoples table says which of them a user is + // allowed to see; that only follows from the items they are credited on. + private InternalItemsQuery? BuildAccessFilter(User? user) + { + if (user is null || !user.HasContentRestrictions()) + { + return null; + } + + var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true }; + _libraryManager.ConfigureUserAccess(accessFilter, user); + return accessFilter; + } + /// <summary> /// Get person by name. /// </summary> diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 048a49ffd4..9bfe0f2570 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -521,7 +521,7 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes) { - var callingUserId = userId ?? User.GetUserId(); + var callingUserId = RequestHelpers.GetUserId(User, userId); var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); if (playlist is null) { diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 2c84fde972..79b8b60cb7 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Attributes; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Api; using MediaBrowser.Common.Plugins; diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index a144961d74..84c2d90fb1 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -306,11 +306,14 @@ public class SessionController : BaseJellyfinApiController [HttpPost("Sessions/{sessionId}/User/{userId}")] [Authorize] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult AddUserToSession( + public async Task<ActionResult> AddUserToSession( [FromRoute, Required] string sessionId, [FromRoute, Required] Guid userId) { - _sessionManager.AddAdditionalUser(sessionId, userId); + _sessionManager.AddAdditionalUser( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + userId); return NoContent(); } @@ -324,11 +327,14 @@ public class SessionController : BaseJellyfinApiController [HttpDelete("Sessions/{sessionId}/User/{userId}")] [Authorize] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveUserFromSession( + public async Task<ActionResult> RemoveUserFromSession( [FromRoute, Required] string sessionId, [FromRoute, Required] Guid userId) { - _sessionManager.RemoveAdditionalUser(sessionId, userId); + _sessionManager.RemoveAdditionalUser( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + userId); return NoContent(); } @@ -352,12 +358,13 @@ public class SessionController : BaseJellyfinApiController [FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsPersistentIdentifier = true) { + var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(id)) { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + id = currentSessionId; } - _sessionManager.ReportCapabilities(id, new ClientCapabilities + _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities { PlayableMediaTypes = playableMediaTypes, SupportedCommands = supportedCommands, @@ -381,12 +388,13 @@ public class SessionController : BaseJellyfinApiController [FromQuery] string? id, [FromBody, Required] ClientCapabilitiesDto capabilities) { + var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(id)) { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + id = currentSessionId; } - _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); + _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities()); return NoContent(); } @@ -405,9 +413,9 @@ public class SessionController : BaseJellyfinApiController [FromQuery] string? sessionId, [FromQuery, Required] string? itemId) { - string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - _sessionManager.ReportNowViewingItem(session, itemId); + _sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index fa6d9efe36..47c8f21241 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -140,6 +140,11 @@ public class StartupController : BaseJellyfinApiController return NotFound(); } + if (!string.IsNullOrEmpty(user.Password)) + { + return Forbid(); + } + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { return BadRequest("Password must not be empty"); diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index a8feb206a4..5bac850859 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -92,6 +92,12 @@ public class StudiosController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + // Asking for a type filter has always implied wanting that type's counts back. + if (includeItemTypes.Length != 0 && !dtoOptions.ContainsField(ItemFields.ItemCounts)) + { + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.ItemCounts]; + } + User? user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); @@ -126,8 +132,7 @@ public class StudiosController : BaseJellyfinApiController } var result = _libraryManager.GetStudios(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index e5df873f5b..c4851091c1 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController if (!string.IsNullOrEmpty(fallbackFontPath)) { var fontFile = _fileSystem.GetFiles(fallbackFontPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); var fileSize = fontFile?.Length; if (fontFile is not null && fileSize is not null && fileSize > 0) diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index e53d15acfd..cdbd1ee7aa 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -133,6 +133,7 @@ public class UniversalAudioController : BaseJellyfinApiController var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId) .ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index a718035528..da03032249 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -34,12 +34,15 @@ namespace Jellyfin.Api.Controllers; [Tags("Library")] public class UserLibraryController : BaseJellyfinApiController { + private static readonly TimeSpan RefreshOnDemandTimeout = TimeSpan.FromSeconds(3); + private readonly IUserManager _userManager; private readonly IUserDataManager _userDataRepository; private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IUserViewManager _userViewManager; private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; /// <summary> /// Initializes a new instance of the <see cref="UserLibraryController"/> class. @@ -50,13 +53,15 @@ public class UserLibraryController : BaseJellyfinApiController /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> public UserLibraryController( IUserManager userManager, IUserDataManager userDataRepository, ILibraryManager libraryManager, IDtoService dtoService, IUserViewManager userViewManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IProviderManager providerManager) { _userManager = userManager; _userDataRepository = userDataRepository; @@ -64,6 +69,7 @@ public class UserLibraryController : BaseJellyfinApiController _dtoService = dtoService; _userViewManager = userViewManager; _fileSystem = fileSystem; + _providerManager = providerManager; } /// <summary> @@ -94,7 +100,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + await RefreshOnDemandIfNeeded(item).ConfigureAwait(false); var dtoOptions = new DtoOptions(); @@ -639,24 +645,36 @@ public class UserLibraryController : BaseJellyfinApiController limit, groupItems); - private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + private async Task RefreshOnDemandIfNeeded(BaseItem item) { - if (item is Person) + if (item is not Person) { - var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + return; + } - if (performFullRefresh) - { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = true - }; - - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } + var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + if (hasMetadata || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 3) + { + return; + } + + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = true + }; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted); + timeout.CancelAfter(RefreshOnDemandTimeout); + + try + { + await item.RefreshMetadata(options, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested) + { + _providerManager.QueueRefresh(item.Id, options, RefreshPriority.High); } } diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 8b359c48af..a934e5dbf8 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -90,7 +90,7 @@ public class UserViewsController : BaseJellyfinApiController var dtoOptions = new DtoOptions(); dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; - var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); + var dtos = _dtoService.GetBaseItemDtos(folders, dtoOptions, user, skipVisibilityCheck: true); return new QueryResult<BaseItemDto>(dtos); } diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 09793553c2..36a8e197a2 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -24,7 +24,7 @@ public static class DtoExtensions case CollectionType.tvshows: return [BaseItemKind.Series]; case CollectionType.music: - return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist]; + return [BaseItemKind.MusicAlbum]; case CollectionType.musicvideos: return [BaseItemKind.MusicVideo]; case CollectionType.books: diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index b09b279699..4995d37f4b 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -458,6 +458,7 @@ public class DynamicHlsHelper { case VideoRangeType.HLG: case VideoRangeType.DOVIWithHLG: + case VideoRangeType.DOVIInvalid when string.Equals(state.VideoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase): builder.Append(",VIDEO-RANGE=HLG"); break; default: diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index ef81235808..c27a18831c 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Entities; @@ -44,6 +45,7 @@ public class MediaInfoHelper private readonly ILogger<MediaInfoHelper> _logger; private readonly INetworkManager _networkManager; private readonly IDeviceManager _deviceManager; + private readonly IServerApplicationHost _appHost; /// <summary> /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class. @@ -56,6 +58,7 @@ public class MediaInfoHelper /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param> public MediaInfoHelper( IUserManager userManager, ILibraryManager libraryManager, @@ -64,7 +67,8 @@ public class MediaInfoHelper IServerConfigurationManager serverConfigurationManager, ILogger<MediaInfoHelper> logger, INetworkManager networkManager, - IDeviceManager deviceManager) + IDeviceManager deviceManager, + IServerApplicationHost appHost) { _userManager = userManager; _libraryManager = libraryManager; @@ -74,6 +78,7 @@ public class MediaInfoHelper _logger = logger; _networkManager = networkManager; _deviceManager = deviceManager; + _appHost = appHost; } /// <summary> @@ -81,40 +86,20 @@ public class MediaInfoHelper /// </summary> /// <param name="item">The item.</param> /// <param name="user">The user.</param> + /// <param name="request">The current <see cref="HttpRequest"/>.</param> /// <param name="mediaSourceId">Media source id.</param> /// <param name="liveStreamId">Live stream id.</param> /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> public async Task<PlaybackInfoResponse> GetPlaybackInfo( BaseItem item, User? user, + HttpRequest request, string? mediaSourceId = null, string? liveStreamId = null) { var result = new PlaybackInfoResponse(); - MediaSourceInfo[] mediaSources; - if (string.IsNullOrWhiteSpace(liveStreamId)) - { - // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? - var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(mediaSourceId)) - { - mediaSources = mediaSourcesList.ToArray(); - } - else - { - mediaSources = mediaSourcesList - .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - else - { - var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - - mediaSources = new[] { mediaSource }; - } + var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(false); if (mediaSources.Length == 0) { @@ -136,6 +121,11 @@ public class MediaInfoHelper mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource; } + foreach (var mediaSource in mediaSourcesClone) + { + RewritePublishedLiveStreamPath(mediaSource, request); + } + result.MediaSources = mediaSourcesClone; } @@ -145,6 +135,28 @@ public class MediaInfoHelper return result; } + private async Task<MediaSourceInfo[]> ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, string? liveStreamId) + { + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + + return new[] { mediaSource }; + } + + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(mediaSourceId)) + { + return mediaSourcesList.ToArray(); + } + + return mediaSourcesList + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + /// <summary> /// SetDeviceSpecificData. /// </summary> @@ -415,6 +427,8 @@ public class MediaInfoHelper { var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request); + var profile = request.DeviceProfile; if (profile is null) { @@ -524,4 +538,90 @@ public class MediaInfoHelper return maxBitrate; } + + /// <summary> + /// Rewrites a Live TV media source's <see cref="MediaSourceInfo.Path"/> to the request-appropriate published + /// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local + /// addresses. Only opened live streams are eligible. The shared instance held by + /// <see cref="IMediaSourceManager"/> is never touched by this method. + /// </summary> + /// <param name="mediaSource">The media source clone to rewrite in place.</param> + /// <param name="request">The current <see cref="HttpRequest"/>.</param> + private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request) + { + // Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources. + if (string.IsNullOrEmpty(mediaSource.LiveStreamId)) + { + return; + } + + var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl; + var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl); + + if (publishedPath is not null) + { + mediaSource.Path = publishedPath; + return; + } + + if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id); + } + } + + /// <summary> + /// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent. + /// Returns null when the path isn't a Jellyfin-hosted <c>/LiveTv/LiveStreamFiles/</c> HTTP URL. + /// </summary> + /// <param name="smartApiUrl">The request-appropriate base URL, as returned by <see cref="IServerApplicationHost.GetSmartApiUrl(HttpRequest)"/>.</param> + /// <param name="localPath">The media source's local (LAN-access) path, as built from <see cref="IServerApplicationHost.GetApiUrlForLocalAccess"/>.</param> + /// <param name="protocol">The media source's protocol.</param> + /// <param name="baseUrl">The server's configured BaseUrl, if any.</param> + /// <returns>The published path, or null if the local path should be left unchanged.</returns> + internal static string? GetPublishedLiveStreamPath( + string smartApiUrl, + string? localPath, + MediaProtocol protocol, + string baseUrl) + { + if (protocol != MediaProtocol.Http + || !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri)) + { + return null; + } + + var relativePath = localUri.PathAndQuery; + if (!string.IsNullOrEmpty(baseUrl)) + { + var basePrefix = baseUrl + "/"; + if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + relativePath = relativePath[baseUrl.Length..]; + } + + if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var prefix = smartApiUrl.TrimEnd('/'); + if (!string.IsNullOrEmpty(baseUrl)) + { + var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri) + && Uri.UnescapeDataString(publishedUri.AbsolutePath) + .TrimEnd('/') + .EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase); + + if (!includesBaseUrl) + { + prefix += baseUrl; + } + } + + return prefix + relativePath; + } } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 5072f902da..4c5ed16015 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -5,7 +5,6 @@ using System.Security.Claims; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; @@ -31,15 +30,16 @@ public static class RequestHelpers /// </summary> /// <param name="sortBy">Sort By. Comma delimited string.</param> /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> + /// <typeparam name="TSortBy">The type of the sort by field.</typeparam> /// <returns>Order By.</returns> - public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) + public static (TSortBy, SortOrder)[] GetOrderBy<TSortBy>(IReadOnlyList<TSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) { if (sortBy.Count == 0) { - return Array.Empty<(ItemSortBy, SortOrder)>(); + return Array.Empty<(TSortBy, SortOrder)>(); } - var result = new (ItemSortBy, SortOrder)[sortBy.Count]; + var result = new (TSortBy, SortOrder)[sortBy.Count]; var i = 0; // Add elements which have a SortOrder specified for (; i < requestedSortOrder.Count; i++) @@ -156,7 +156,6 @@ public static class RequestHelpers QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, DtoOptions dtoOptions, IDtoService dtoService, - bool includeItemTypes, User? user) { var dtos = result.Items.Select(i => @@ -164,7 +163,7 @@ public static class RequestHelpers var (baseItem, counts) = i; var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (includeItemTypes) + if (counts is not null) { dto.ChildCount = counts.ItemCount; dto.ProgramCount = counts.ProgramCount; @@ -175,6 +174,7 @@ public static class RequestHelpers dto.AlbumCount = counts.AlbumCount; dto.SongCount = counts.SongCount; dto.ArtistCount = counts.ArtistCount; + dto.MusicVideoCount = counts.MusicVideoCount; } return dto; diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 6a6aac1327..60c5bb2ef6 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -198,11 +198,6 @@ public static class StreamingHelpers state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0; } - if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal)) - { - containerInternal = ".pcm"; - } - if (state.VideoRequest is not null) { state.OutputVideoCodec = state.Request.VideoCodec; diff --git a/Jellyfin.Data/Enums/VideoRangeType.cs b/Jellyfin.Data/Enums/VideoRangeType.cs index ce232d73c3..e7cc340d21 100644 --- a/Jellyfin.Data/Enums/VideoRangeType.cs +++ b/Jellyfin.Data/Enums/VideoRangeType.cs @@ -61,8 +61,9 @@ public enum VideoRangeType DOVIWithELHDR10Plus, /// <summary> - /// Dolby Vision with invalid configuration. e.g. Profile 8 compat id 6. - /// When using this range, the server would assume the video is still HDR10 after removing the Dolby Vision metadata. + /// Dolby Vision with invalid configuration, e.g. Profile 8 compat id 6 or inconsistent base-layer color metadata. + /// The base layer is classified as HDR only when its transfer characteristics signal PQ or HLG. + /// Otherwise, it is classified as SDR. /// </summary> DOVIInvalid, diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index c8983480c7..4b42409d29 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -18,7 +18,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Data</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Jellyfin.Data/UserEntityExtensions.cs b/Jellyfin.Data/UserEntityExtensions.cs index 0fc8d3cd25..c6468daa2e 100644 --- a/Jellyfin.Data/UserEntityExtensions.cs +++ b/Jellyfin.Data/UserEntityExtensions.cs @@ -163,6 +163,23 @@ public static class UserEntityExtensions } /// <summary> + /// Checks whether any library, parental rating or tag rule keeps content from this user. + /// </summary> + /// <param name="entity">The user to check.</param> + /// <returns><c>True</c> if some content in the library is hidden from this user.</returns> + public static bool HasContentRestrictions(this User entity) + { + ArgumentNullException.ThrowIfNull(entity); + + return !entity.HasPermission(PermissionKind.EnableAllFolders) + || entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0 + || entity.MaxParentalRatingScore.HasValue + || entity.GetPreference(PreferenceKind.BlockedTags).Length > 0 + || entity.GetPreference(PreferenceKind.AllowedTags).Length > 0 + || entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0; + } + + /// <summary> /// Initializes the default permissions for a user. Should only be called on user creation. /// </summary> /// <param name="entity">The entity to update.</param> diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs index d70ac672f2..0f166fc6e0 100644 --- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -40,6 +40,19 @@ public static class ExpressionExtensions } /// <summary> + /// Negates a predicate. + /// </summary> + /// <typeparam name="T">The predicate parameter type.</typeparam> + /// <param name="predicate">The predicate expression to negate.</param> + /// <returns>A new expression representing the negation of the input predicate.</returns> + public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters); + } + + /// <summary> /// Combines two predicates into a single predicate using a logical AND operation. /// </summary> /// <typeparam name="T">The predicate parameter type.</typeparam> diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index a534fa5fa0..7c10a5dc77 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -359,18 +359,39 @@ public class BackupService : IBackupService jsonSerializer.WriteStartArray(); var set = entityType.ValueFactory().ConfigureAwait(false); - await foreach (var item in set.ConfigureAwait(false)) + var enumerator = set.GetAsyncEnumerator(); + await using (enumerator) { - entities++; - try + while (true) { - using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); - document.WriteTo(jsonSerializer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not load entity {Entity}", item); - throw; + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName); + continue; + } + + if (!hasNext) + { + break; + } + + var item = enumerator.Current; + entities++; + try + { + using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); + document.WriteTo(jsonSerializer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not load entity {Entity}", item); + throw; + } } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index c64e6ac068..6405c8c45d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -34,6 +34,40 @@ public static class BaseItemMapper /// </summary> private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>(); + private static UserData[] DetachUserData(BaseItemEntity entity) + { + if (entity.UserData is null || entity.UserData.Count == 0) + { + return []; + } + + var detached = new UserData[entity.UserData.Count]; + var index = 0; + foreach (var userData in entity.UserData) + { + detached[index++] = new UserData + { + ItemId = userData.ItemId, + Item = null, + UserId = userData.UserId, + User = null, + CustomDataKey = userData.CustomDataKey, + Rating = userData.Rating, + PlaybackPositionTicks = userData.PlaybackPositionTicks, + PlayCount = userData.PlayCount, + IsFavorite = userData.IsFavorite, + LastPlayedDate = userData.LastPlayedDate, + Played = userData.Played, + AudioStreamIndex = userData.AudioStreamIndex, + SubtitleStreamIndex = userData.SubtitleStreamIndex, + Likes = userData.Likes, + RetentionDate = userData.RetentionDate + }; + } + + return detached; + } + /// <summary> /// Maps a Entity to the DTO. /// </summary> @@ -87,7 +121,7 @@ public static class BaseItemMapper dto.OwnerId = entity.OwnerId ?? Guid.Empty; dto.Width = entity.Width.GetValueOrDefault(); dto.Height = entity.Height.GetValueOrDefault(); - dto.UserData = entity.UserData; + dto.UserData = DetachUserData(entity); if (entity.Provider is not null) { @@ -134,6 +168,21 @@ public static class BaseItemMapper if (dto is Video video) { video.PrimaryVersionId = entity.PrimaryVersionId; + + // The LinkedChildren table is the source of truth for version links + if (entity.LinkedChildEntities is not null) + { + video.LinkedAlternateVersions = entity.LinkedChildEntities + // LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here + .Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion) + .OrderBy(e => e.SortOrder) + .Select(e => new LinkedChild + { + ItemId = e.ChildId, + Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType + }) + .ToArray(); + } } if (dto is IHasSeries hasSeriesName) @@ -183,7 +232,7 @@ public static class BaseItemMapper if (dto is Folder folder) { folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0) + if (entity.LinkedChildEntities is not null) { folder.LinkedChildren = entity.LinkedChildEntities .OrderBy(e => e.SortOrder) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index c5b5fbf6d8..51ac146a6f 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -4,9 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; @@ -81,24 +83,89 @@ public sealed partial class BaseItemRepository _itemTypeLookup.MusicGenreTypes); } - private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType) { + ArgumentNullException.ThrowIfNull(filter); + using var context = _dbProvider.CreateDbContext(); - var query = context.ItemValuesMap + return TranslateQuery( + context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)), + context, + new InternalItemsQuery(filter.User) + { + IncludeOwnedItems = filter.IncludeOwnedItems, + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsAiring = filter.IsAiring, + IsMovie = filter.IsMovie, + IsSports = filter.IsSports, + IsKids = filter.IsKids, + IsNews = filter.IsNews, + IsSeries = filter.IsSeries + }) + .SelectMany(e => e.MediaStreams!) + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetTagNames(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); + + var matchingItemIds = baseQuery.Select(e => e.Id); + + // Project the join before grouping. Grouping over the ItemValue navigation instead makes EF + // re-resolve the aggregate as a correlated subquery per group, which is orders of magnitude slower. + return context.ItemValuesMap .AsNoTracking() - .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type)); + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Tags) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) + .GroupBy(iv => iv.CleanValue) + .Select(g => g.Min(iv => iv.Value)!) + .OrderBy(t => t) + .ToArray(); + } + + private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) + { + using var context = _dbProvider.CreateDbContext(); + + var maps = context.ItemValuesMap.AsNoTracking(); if (withItemTypes.Count > 0) { - query = query.Where(e => withItemTypes.Contains(e.Item.Type)); + maps = maps.Where(e => withItemTypes.Contains(e.Item.Type)); } if (excludeItemTypes.Count > 0) { - query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type)); + maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type)); } - return query.Select(e => e.ItemValue) + return context.ItemValues + .AsNoTracking() + .WhereOneOrMany(itemValueTypes, e => e.Type) + .Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId)) + .Select(e => new { e.CleanValue, e.Value }) .GroupBy(e => e.CleanValue) .Select(g => g.Min(v => v.Value)!) .ToArray(); @@ -108,11 +175,6 @@ public sealed partial class BaseItemRepository { ArgumentNullException.ThrowIfNull(filter); - if (!filter.Limit.HasValue) - { - filter.EnableTotalRecordCount = false; - } - using var context = _dbProvider.CreateDbContext(); var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User) @@ -132,21 +194,16 @@ public sealed partial class BaseItemRepository IsSeries = filter.IsSeries }); - // Keep this as an IQueryable sub-select. Materializing to a list would inline one - // bound parameter per CleanValue and hit SQLite's variable cap on libraries with - // high-cardinality value types (e.g. tens of thousands of artists). - var matchingCleanValues = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Join( - innerQueryFilter, - ivm => ivm.ItemId, - g => g.Id, - (ivm, g) => ivm.ItemValue.CleanValue) - .Distinct(); - var innerQuery = PrepareItemQuery(context, filter) .Where(e => e.Type == returnType) - .Where(e => matchingCleanValues.Contains(e.CleanName!)); + .Where(e => context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName) + .Join( + innerQueryFilter, + ivm => ivm.ItemId, + g => g.Id, + (ivm, g) => ivm.ItemId) + .Any()); var outerQueryFilter = new InternalItemsQuery(filter.User) { @@ -169,32 +226,42 @@ public sealed partial class BaseItemRepository ExcludeItemIds = filter.ExcludeItemIds }; - // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking - // the lowest Id per group. For MusicArtist, prefer the entity from a library the user - // can actually access,since the same artist can have a folder in multiple libraries. - // Keep as an IQueryable sub-select so paging is applied AFTER - // ApplyOrder runs the caller's actual sort. + // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one + // representative id per group, then materialize the representative ids once. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var representativeIds = isMusicArtist - ? masterQuery + List<Guid> representativeIds; + if (isMusicArtist) + { + // For MusicArtist, prefer the entity from a library the user can actually access. + // Materialize to prevent correlated per-group first-row queries which hurt performance. + var topParentIds = filter.TopParentIds; + representativeIds = masterQuery + .Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId }) + .AsEnumerable() .GroupBy(e => e.PresentationUniqueKey) .Select(g => g - .OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) + .OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) .ThenBy(e => e.Id) .First().Id) - : masterQuery + .ToList(); + } + else + { + representativeIds = masterQuery .GroupBy(e => e.PresentationUniqueKey) - .Select(g => g.Min(e => e.Id)); + .Select(g => g.Min(e => e.Id)) + .ToList(); + } var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) { - result.TotalRecordCount = representativeIds.Count(); + result.TotalRecordCount = representativeIds.Count; } var query = ApplyNavigations( - context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)), + context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id), filter); query = ApplyOrder(query, filter, context); @@ -210,14 +277,20 @@ public sealed partial class BaseItemRepository } result.StartIndex = filter.StartIndex ?? 0; - if (filter.IncludeItemTypes.Length > 0) + var page = query.AsEnumerable().Where(e => e is not null).ToList(); + + if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts)) { - var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes); + var pageCleanNames = page + .Where(e => !string.IsNullOrEmpty(e.CleanName)) + .Select(e => e.CleanName!) + .Distinct() + .ToList(); + + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames); result.Items = [ - .. query - .AsEnumerable() - .Where(e => e is not null) + .. page .Select(e => { var item = DeserializeBaseItem(e, filter.SkipDeserialization); @@ -232,9 +305,7 @@ public sealed partial class BaseItemRepository { result.Items = [ - .. query - .AsEnumerable() - .Where(e => e != null) + .. page .Select(e => DeserializeBaseItem(e, filter.SkipDeserialization)) .Where(item => item != null) .Select(item => (item!, (ItemCounts?)null)) @@ -245,14 +316,22 @@ public sealed partial class BaseItemRepository } private Dictionary<string, ItemCounts> BuildItemCountsByCleanName( - Database.Implementations.JellyfinDbContext context, + JellyfinDbContext context, InternalItemsQuery filter, - IReadOnlyList<ItemValueType> itemValueTypes) + IReadOnlyList<ItemValueType> itemValueTypes, + IReadOnlyList<string> cleanNames) { - var typeSubQuery = new InternalItemsQuery(filter.User) + var countsByCleanName = new Dictionary<string, ItemCounts>(); + if (cleanNames.Count == 0) + { + return countsByCleanName; + } + + // The counts describe everything the value is attached to, not only the types the list was + // filtered down to. + var scopeQuery = new InternalItemsQuery(filter.User) { ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, MediaTypes = filter.MediaTypes, AncestorIds = filter.AncestorIds, ExcludeItemIds = filter.ExcludeItemIds, @@ -262,42 +341,115 @@ public sealed partial class BaseItemRepository IsPlayed = filter.IsPlayed }; - var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery); + var valueLinks = context.ItemValuesMap + .AsNoTracking() + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var itemIds = itemCountQuery.Select(e => e.Id); // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) - // Instead, start from ItemValueMaps and join with BaseItems - return context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Where(ivm => itemIds.Contains(ivm.ItemId)) + // Instead, start from ItemValueMaps and join with BaseItems. + var rawCounts = valueLinks + .Join( + scopedItems, + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId, e.Id }) + .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId }) + .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToList(); + + // Only studios and genres pass down from a series to its episodes; an artist credit does not. + var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre); + var episodeCounts = inheritsToEpisodes + ? BuildEpisodeCountsByCleanName( + scopedItems, + valueLinks, + rawCounts + .Where(x => x.Type == episodeTypeName) + .Select(x => (x.CleanName, x.SeriesId, x.Count)) + .ToList(), + seriesTypeName, + episodeTypeName) + : rawCounts + .Where(x => x.Type == episodeTypeName) + .GroupBy(x => x.CleanName) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Count)); + + foreach (var group in rawCounts.GroupBy(x => x.CleanName)) + { + var counts = ItemCountBuilder.Build(_itemTypeLookup, group.Select(row => (row.Type, row.Count))); + + // Episodes are counted separately: the value is usually only written on the series. + ItemCountBuilder.SetEpisodeCount(counts, episodeCounts.GetValueOrDefault(group.Key)); + countsByCleanName[group.Key] = counts; + } + + // A value carried by nothing but the episodes below a tagged series has no row of its own. + foreach (var (cleanName, episodeCount) in episodeCounts) + { + if (!countsByCleanName.ContainsKey(cleanName)) + { + var counts = new ItemCounts(); + ItemCountBuilder.SetEpisodeCount(counts, episodeCount); + countsByCleanName[cleanName] = counts; + } + } + + return countsByCleanName; + } + + private static Dictionary<string, int> BuildEpisodeCountsByCleanName( + IQueryable<BaseItemEntity> scopedItems, + IQueryable<ItemValueMap> valueLinks, + IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes, + string seriesTypeName, + string episodeTypeName) + { + // Resolved in steps rather than as one union: each of these drives off an index, while the + // single-statement form leaves SQLite free to scan every episode in the library instead. + var taggedSeries = valueLinks .Join( - context.BaseItems, + scopedItems.Where(e => e.Type == seriesTypeName), ivm => ivm.ItemId, e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) - .GroupBy(x => new { x.CleanName, x.Type }) - .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) - .GroupBy(x => x.CleanName) - .ToDictionary( - g => g.Key, - g => new ItemCounts - { - SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), - EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), - MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), - AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), - ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), - SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), - TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), - }); + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .ToList(); + + var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray(); + var episodesPerSeries = seriesIds.Length == 0 + ? [] + : scopedItems + .Where(e => e.Type == episodeTypeName && e.SeriesId != null) + .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value) + .GroupBy(e => e.SeriesId!.Value) + .Select(g => new { SeriesId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeriesId, x => x.Count); + + var episodeCounts = new Dictionary<string, int>(); + var seriesByCleanName = new Dictionary<string, HashSet<Guid>>(); + foreach (var group in taggedSeries.GroupBy(x => x.CleanName)) + { + var series = group.Select(x => x.SeriesId).ToHashSet(); + seriesByCleanName[group.Key] = series; + episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id)); + } + + foreach (var (cleanName, seriesId, count) in taggedEpisodes) + { + if (seriesId is not null + && seriesByCleanName.TryGetValue(cleanName, out var series) + && series.Contains(seriesId.Value)) + { + continue; + } + + episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count; + } + + return episodeCounts; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index decd45ae2c..1a4c9da41c 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -35,11 +36,40 @@ public sealed partial class BaseItemRepository { dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter); return dbQuery; } + /// <summary> + /// Trims an ordered query down to the AdjacentTo item and its immediate neighbours. + /// </summary> + private IQueryable<BaseItemEntity> ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + if (filter.AdjacentTo.IsNullOrEmpty()) + { + return dbQuery; + } + + // Adjacency is relative to the result set and the order the query asked for, so the ids have + // to be read back in that order. + var orderedIds = dbQuery.Select(e => e.Id).ToList(); + var index = orderedIds.IndexOf(filter.AdjacentTo.Value); + if (index < 0) + { + // The item isn't part of this result set, so it has no neighbours in it either. + return dbQuery.Take(0); + } + + var start = Math.Max(index - 1, 0); + var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start); + + var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id)); + + return ApplyOrder(adjacentQuery, filter, context); + } + private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { if (filter.Limit.HasValue || filter.StartIndex.HasValue) @@ -241,11 +271,11 @@ public sealed partial class BaseItemRepository if (filter.DtoOptions.EnableImages) { - dbQuery = dbQuery.Include(e => e.Images); + dbQuery = dbQuery.Include(e => e.Images!.OrderBy(i => i.Id)); } - // Include LinkedChildEntities for container types and videos that use them - // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions). + // Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist, + // CollectionFolder for manual linking; every video type for alternate versions). // When IncludeItemTypes is empty (any type may be returned), always include them to ensure // LinkedChildren are loaded before items are saved back, preventing accidental deletion. var linkedChildTypes = new[] @@ -254,11 +284,14 @@ public sealed partial class BaseItemRepository BaseItemKind.Playlist, BaseItemKind.CollectionFolder, BaseItemKind.Video, - BaseItemKind.Movie + BaseItemKind.Movie, + BaseItemKind.Episode, + BaseItemKind.MusicVideo, + BaseItemKind.Trailer }; if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) { - dbQuery = dbQuery.Include(e => e.LinkedChildEntities); + dbQuery = dbQuery.Include(e => e.LinkedChildEntities!.OrderBy(l => l.SortOrder)); } if (filter.IncludeExtras) @@ -291,10 +324,21 @@ public sealed partial class BaseItemRepository orderedQuery = query.OrderBy(relevanceExpression); } + // Folders carry no played flag of their own, so these two keys go through the same predicate + // the isPlayed filter uses rather than through the stored-column lookup in OrderMapper. + Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy) => sortBy switch + { + ItemSortBy.IsPlayed when filter.User is not null + => AsOrderKey(BuildIsPlayedFilter(context, filter.User)), + ItemSortBy.IsUnplayed when filter.User is not null + => AsOrderKey(BuildIsPlayedFilter(context, filter.User).Not()), + _ => OrderMapper.MapOrderByField(sortBy, filter, context) + }; + if (orderBy.Length > 0) { var firstOrdering = orderBy[0]; - var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); + var expression = MapOrderByField(firstOrdering.OrderBy); if (orderedQuery is null) { @@ -318,7 +362,7 @@ public sealed partial class BaseItemRepository foreach (var item in orderBy.Skip(1)) { - expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); + expression = MapOrderByField(item.OrderBy); orderedQuery = item.SortOrder == SortOrder.Ascending ? orderedQuery.ThenBy(expression) : orderedQuery.ThenByDescending(expression); @@ -390,12 +434,24 @@ public sealed partial class BaseItemRepository var baseQuery = context.BaseItems .AsNoTracking() - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); return ApplyAccessFiltering(context, baseQuery, filter); } /// <summary> + /// Checks whether the user restricts access to items by parental rating or tags. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns><c>true</c> if the query carries parental restrictions.</returns> + private static bool RequiresParentalRestrictions(InternalItemsQuery filter) + => filter.IncludeInheritedTags.Length > 0 + || filter.ExcludeInheritedTags.Length > 0 + || filter.MaxParentalRating is not null + || filter.BlockUnratedItems.Length > 0; + + /// <summary> /// Applies user access filtering to a query. /// Includes TopParentIds, parental rating, and tag filtering. /// </summary> @@ -405,13 +461,134 @@ public sealed partial class BaseItemRepository IQueryable<BaseItemEntity> baseQuery, InternalItemsQuery filter) { - // Apply TopParentIds filtering (library folder access) - if (filter.TopParentIds.Length > 0) + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); + + baseQuery = ApplyParentalRestrictions(context, baseQuery, filter); + + // Hide alternate versions behind the primary of their library, and exclude owned non-extra + // items. Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those. + if (!filter.IncludeOwnedItems) + { + baseQuery = ApplyAlternateVersionFiltering(context, baseQuery) + .Where(e => e.OwnerId == null || e.ExtraType != null); + } + + return baseQuery; + } + + private static IQueryable<BaseItemEntity> ApplyAlternateVersionFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery) + => baseQuery.Where(e => e.PrimaryVersionId == null + || !context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId)); + + /// <summary> + /// Restricts a query to the libraries the user may open, exempting requested by-name items. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyTopParentFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { + var queryTopParentIds = filter.TopParentIds; + if (queryTopParentIds.Length == 0) + { + return baseQuery; + } + + var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter); + if (exemptedItemByNameTypes.Count == 0) + { + return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); + } + + baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); + if (filter.UserHasContentRestrictions) + { + baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds); + } + + return baseQuery; + } + + /// <summary> + /// Returns the by-name types a query asks for, which carry no TopParentId to filter on. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The type names exempt from library filtering.</returns> + private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter) + { + var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); + if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0) { - var topParentIds = filter.TopParentIds; - baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value)); + return includedItemByNameTypes; } + return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList(); + } + + /// <summary> + /// Keeps a by-name row only when at least one item behind its name is reachable for the user. + /// </summary> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <param name="itemByNameTypes">The exempted by-name type names.</param> + /// <param name="topParentIds">The libraries the user may open.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering( + IQueryable<BaseItemEntity> baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList<string> itemByNameTypes, + Guid[] topParentIds) + { + // IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts. + var accessibleItems = ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking(), + new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true }); + + var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + if (itemByNameTypes.Contains(personType)) + { + baseQuery = baseQuery.Where(e => e.Type != personType + || context.Peoples.Any(p => p.Name == e.Name + && context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId)))); + } + + foreach (var (kind, valueTypes) in _itemByNameValueTypes) + { + var typeName = _itemTypeLookup.BaseItemKindNames[kind]; + if (!itemByNameTypes.Contains(typeName)) + { + continue; + } + + baseQuery = baseQuery.Where(e => e.Type != typeName + || context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName + && context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId)))); + } + + return baseQuery; + } + + /// <summary> + /// Applies the user's parental rating and tag restrictions to a query. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to filter.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered query.</returns> + private IQueryable<BaseItemEntity> ApplyParentalRestrictions( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { // Apply parental rating filtering if (filter.MaxParentalRating is not null) { @@ -447,6 +624,7 @@ public sealed partial class BaseItemRepository if (filter.IncludeInheritedTags.Length > 0) { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); @@ -455,14 +633,10 @@ public sealed partial class BaseItemRepository allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) - || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))); - } + || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) - // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. - // Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those. - if (!filter.IncludeOwnedItems) - { - baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName); } return baseQuery; @@ -478,83 +652,57 @@ public sealed partial class BaseItemRepository { var maxScore = maxRating.Score; var maxSubScore = maxRating.SubScore ?? 0; - var linkedChildren = context.LinkedChildren; + + // Only a manual link makes an item a container of other items. + var members = context.LinkedChildren + .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual); return e => - // Item has a rating: check against limit - (e.InheritedParentalRatingValue != null - && (e.InheritedParentalRatingValue < maxScore - || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))) - // Item has no rating - || (e.InheritedParentalRatingValue == null - && ( - // No linked children (not a BoxSet/Playlist): pass as unrated - !linkedChildren.Any(lc => lc.ParentId == e.Id) - // Has linked children: at least one child must be within limits - || linkedChildren.Any(lc => lc.ParentId == e.Id - && (lc.Child!.InheritedParentalRatingValue == null - || lc.Child.InheritedParentalRatingValue < maxScore - || (lc.Child.InheritedParentalRatingValue == maxScore - && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))))); + // The item's own rating, where it has one, has to be within the limit. An unrated item + // passes here; blocking those is what BlockUnratedItems does. + (e.InheritedParentalRatingValue == null + || e.InheritedParentalRatingValue < maxScore + || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)) + // A container is only as visible as its members: a BoxSet or Playlist with nothing left + // in it for this user is hidden whatever rating it carries itself. BoxSet.IsVisible + // applies the same rule in memory, and a count has to agree with the listing it counts. + && (!members.Any(lc => lc.ParentId == e.Id) + || members.Any(lc => lc.ParentId == e.Id + && (lc.Child!.InheritedParentalRatingValue == null + || lc.Child.InheritedParentalRatingValue < maxScore + || (lc.Child.InheritedParentalRatingValue == maxScore + && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))); } /// <inheritdoc /> - public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user) + public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(folderIds); ArgumentNullException.ThrowIfNull(user); - var filter = new InternalItemsQuery(user); - var userId = user.Id; - var leafItems = context.BaseItems .AsNoTracking() - .Where(b => !b.IsFolder && !b.IsVirtualItem); - leafItems = ApplyAccessFiltering(context, leafItems, filter); - - var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); + .Where(DescendantQueryHelper.IsCountableLeaf); - var ancestorLeaves = context.AncestorIds - .Where(a => folderIds.Contains(a.ParentItemId)) - .Join( - playedLeafItems, - a => a.ItemId, - b => b.Id, - (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); + return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems }); + } - var linkedLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - playedLeafItems, - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); + private static Expression<Func<BaseItemEntity, object?>> AsOrderKey(Expression<Func<BaseItemEntity, bool>> predicate) + => Expression.Lambda<Func<BaseItemEntity, object?>>(Expression.Convert(predicate.Body, typeof(object)), predicate.Parameters); - var linkedFolderLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - context.BaseItems.Where(b => b.IsFolder), - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) - .Join( - context.AncestorIds, - x => x.FolderChildId, - a => a.ParentItemId, - (x, a) => new { x.ParentId, DescendantId = a.ItemId }) - .Join( - playedLeafItems, - x => x.DescendantId, - b => b.Id, - (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - - return ancestorLeaves - .Union(linkedLeaves) - .Union(linkedFolderLeaves) - .GroupBy(x => x.FolderId) - .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()) - .Select(g => g.Key); + /// <inheritdoc /> + public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(descendants); + + // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as + // linked children, which can themselves be folders contributing their own descendants. + // Every step is a correlated index seek, so only the rows the outer query keeps are visited + // and a folder is left as soon as its first matching descendant is found. + return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId)) + || context.LinkedChildren.Any(lc => lc.ParentId == e.Id + && (descendants.Any(d => d.Id == lc.ChildId) + || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId)))); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index dc16c3b1b3..8d573569e9 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); if (filter.EnableTotalRecordCount) { @@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random); @@ -108,7 +110,7 @@ public sealed partial class BaseItemRepository PrepareFilterQuery(filter); // Early exit if collection type is not supported - if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music) + if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown) { return []; } @@ -119,45 +121,61 @@ public sealed partial class BaseItemRepository var baseQuery = PrepareItemQuery(context, filter); baseQuery = TranslateQuery(baseQuery, context, filter); - if (collectionType == CollectionType.tvshows) + if (collectionType is CollectionType.tvshows) { return GetLatestTvShowItems(context, baseQuery, filter, limit); } if (collectionType is CollectionType.movies) { - // Group by PresentationUniqueKey, pick the newest item per group. - var topGroupItems = baseQuery - .Where(e => e.PresentationUniqueKey != null) - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => new - { - MaxDate = g.Max(e => e.DateCreated), - FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First() - }) - .OrderByDescending(g => g.MaxDate); - - var firstIdsQuery = filter.Limit.HasValue - ? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId) - : topGroupItems.Select(g => g.FirstId); - - return LoadLatestByIds(context, firstIdsQuery, filter); + return GetLatestMovieItems(context, baseQuery, filter, limit); } - // Albums whose Id is the parent of any track matching the user's filter. - var albumIdsWithMatchingTrack = context.AncestorIds - .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId); + if (collectionType is CollectionType.unknown) + { + var moviesQuery = baseQuery.Where(e => e.SeriesName == null); + var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit); + var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit); + + return latestMovies.Concat(latestShows) + .OrderByDescending(dto => dto.DateCreated) + .ThenByDescending(dto => dto.Id) + .Take(limit ?? int.MaxValue) + .ToList(); + } var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!; - var topAlbumsQuery = context.BaseItems.AsNoTracking() - .Where(album => album.Type == musicAlbumTypeName) - .Where(album => albumIdsWithMatchingTrack.Contains(album.Id)) + IQueryable<BaseItemEntity> topAlbumsQuery; + + // When the query is scoped to whole libraries, read the newest albums directly by their own TopParentId. + if (filter.TopParentIds.Length > 0) + { + topAlbumsQuery = context.BaseItems.AsNoTracking() + .Where(album => album.Type == musicAlbumTypeName + && !album.IsVirtualItem + && album.TopParentId.HasValue) + .WhereOneOrMany(filter.TopParentIds, album => album.TopParentId!.Value); + } + else + { + // Fallback (e.g. AncestorIds-scoped callers): albums that are the parent of a matching track. + var albumIdsWithMatchingTrack = context.AncestorIds + .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId); + topAlbumsQuery = context.BaseItems.AsNoTracking() + .Where(album => album.Type == musicAlbumTypeName) + .Where(album => albumIdsWithMatchingTrack.Contains(album.Id)); + } + + // The album is what gets returned, and neither branch above reads it through the + // user's filters, so its own parental restrictions have to be applied here: a + // matching track does not make an album the user may not see visible. + var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter) .OrderByDescending(album => album.DateCreated) .ThenByDescending(album => album.Id); - var albumIdsQuery = filter.Limit.HasValue - ? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id) - : topAlbumsQuery.Select(a => a.Id); + var albumIdsQuery = limit.HasValue + ? orderedAlbums.Take(limit.Value).Select(a => a.Id) + : orderedAlbums.Select(a => a.Id); return LoadLatestByIds(context, albumIdsQuery, filter); } @@ -181,6 +199,62 @@ public sealed partial class BaseItemRepository .ToArray()!; } + private IReadOnlyList<BaseItemDto> LoadLatestByIds( + JellyfinDbContext context, + List<Guid> ids, + InternalItemsQuery filter) + { + if (ids.Count == 0) + { + return []; + } + + var itemsQuery = ApplyNavigations( + context.BaseItems.AsNoTracking().WhereOneOrMany(ids, e => e.Id), + filter); + + return itemsQuery + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .AsEnumerable() + .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) + .Where(dto => dto != null) + .ToArray()!; + } + + /// <summary> + /// Gets the latest movies, deduplicated so each movie only appears once. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to pull movies from, with filters already applied.</param> + /// <param name="filter">The original query filter, used when loading the final items.</param> + /// <param name="limit">How many items to return.</param> + /// <returns>The latest movies, newest first.</returns> + private IReadOnlyList<BaseItemDto> GetLatestMovieItems( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter, + int? limit) + { + // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those. + // Build up until limit by streaming through results and deduplicating on the fly. + var orderedIds = baseQuery + .Where(e => e.PresentationUniqueKey != null) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }); + + // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read. + var firstIds = orderedIds + .AsEnumerable() + .DistinctBy(row => row.PresentationUniqueKey) + .Select(row => row.Id) + .Take(limit ?? int.MaxValue) + .ToList(); + + return LoadLatestByIds(context, firstIds, filter); + } + /// <summary> /// Gets the latest TV show items with smart Season/Series container selection. /// </summary> @@ -381,6 +455,40 @@ public sealed partial class BaseItemRepository seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId)); } + // Step 5b: A container is what gets returned, so it has to pass the user's access + // filters on its own - a matching episode does not make a Season or Series the user + // may not see visible. Containers that don't pass are replaced by their episode. + if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0) + { + var allowedContainerIds = ApplyParentalRestrictions( + context, + context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)), + filter) + .Select(e => e.Id) + .ToHashSet(); + + for (var i = 0; i < seriesResults.Count; i++) + { + var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i]; + if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value)) + { + seasonId = null; + } + + if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value)) + { + seriesId = null; + } + + if (seasonId is null && seriesId is null) + { + entitiesToFetch.Add(mostRecentEpisodeId); + } + + seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId); + } + } + // Step 6: Fetch the Season/Series entities we decided to return var entities = entitiesToFetch.Count > 0 ? ApplyNavigations( @@ -485,10 +593,10 @@ public sealed partial class BaseItemRepository return dbContext.BaseItems .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem) - .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + .All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } - return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } /// <inheritdoc /> @@ -518,18 +626,26 @@ public sealed partial class BaseItemRepository .ToArray(); var tags = context.ItemValuesMap - .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags) - .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) - .Select(ivm => ivm.ItemValue) + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Tags) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) .GroupBy(iv => iv.CleanValue) .Select(g => g.Min(iv => iv.Value)) .OrderBy(t => t) .ToArray(); var genres = context.ItemValuesMap - .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre) - .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) - .Select(ivm => ivm.ItemValue) + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Genre) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) .GroupBy(iv => iv.CleanValue) .Select(g => g.Min(iv => iv.Value)) .OrderBy(g => g) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 52cebccc37..d726f0f143 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -31,6 +31,53 @@ public sealed partial class BaseItemRepository private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter + // instance across several lambdas, and this filter is combined into a tree more than once. + private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder; + + // Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree. + private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user) + { + // Folders (Series, Seasons, BoxSets, albums, ...) carry no played state of their own and count + // as played once no descendant is left unplayed. + var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user) + .Where(BuildLeafIsPlayedFilter(context, user.Id).Not()); + + return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) + .Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id))); + } + + private static Expression<Func<BaseItemEntity, bool>> BuildLeafIsPlayedFilter(JellyfinDbContext context, Guid userId) + { + var playedItemIds = context.UserData + .Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => ud.ItemId); + + // The primaries of every version group holding a played row, whichever version carries it. + var playedGroupIds = context.BaseItems + .Where(v => v.PrimaryVersionId != null + && context.UserData.Any(ud => ud.UserId == userId + && ud.Played + && (ud.ItemId == v.Id || ud.ItemId == v.PrimaryVersionId))) + .Select(v => v.PrimaryVersionId!.Value); + + return e => playedItemIds.Contains(e.Id) + || playedGroupIds.Contains(e.Id) + || (e.PrimaryVersionId != null && playedGroupIds.Contains(e.PrimaryVersionId.Value)); + } + + // "und" is the language filters' stand-in for a track that declares no language at all. + private static string NormalizeLanguage(string language) + => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language; + + // The primary versions whose alternate version satisfies a dimension bound. Anchored on + // PrimaryVersionId so the filtered index carries it rather than a scan of every item. + private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound) + => context.BaseItems + .Where(v => v.PrimaryVersionId != null) + .Where(bound) + .Select(v => v.PrimaryVersionId!.Value); + /// <inheritdoc /> public IQueryable<BaseItemEntity> TranslateQuery( IQueryable<BaseItemEntity> baseQuery, @@ -66,47 +113,86 @@ public sealed partial class BaseItemRepository include4K = true; } - // Non-folders: check own resolution directly (no subquery). - // Folders (Series, BoxSets): EXISTS check on descendants/linked children. - // Using navigation properties (a.Item, lc.Child) produces efficient - // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries. + // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters + // on is the best any of the item's versions offers, not just the primary file's. Three sets, + // because a bucket is as much about what the version group does not have as what it does, and + // because an unprobed primary can still be placed by a version that does carry dimensions. + // The filtered PrimaryVersionId index keeps all three to the few items that have versions. + var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth); + var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth); + var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight); + + // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row + // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself + // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather + // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed + // exactly as a top-level item is so that the two cannot disagree; the navigation properties + // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...). baseQuery = baseQuery.Where(e => - (!e.IsFolder && e.Width > 0 - && ((includeSD && e.Width < HDWidth) - || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) - || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight)))) + (!e.IsFolder + && ((includeSD + && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id)) + && !versionsHd.Contains(e.Id) + && !versions4K.Contains(e.Id)) + || (includeHD + && (e.Width >= HDWidth || versionsHd.Contains(e.Id)) + && !(e.Width >= UHDWidth || e.Height >= UHDHeight) + && !versions4K.Contains(e.Id)) + || (include4K + && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id))))) || (e.IsFolder && (e.Children!.Any(a => - a.Item.Width > 0 - && ((includeSD && a.Item.Width < HDWidth) - || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)) - || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)))) + (includeSD + && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId)) + && !versionsHd.Contains(a.ItemId) + && !versions4K.Contains(a.ItemId)) + || (includeHD + && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId)) + && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight) + && !versions4K.Contains(a.ItemId)) + || (include4K + && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId)))) || context.LinkedChildren.Any(lc => lc.ParentId == e.Id - && lc.Child!.Width > 0 - && ((includeSD && lc.Child.Width < HDWidth) - || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)) - || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))))))); - } - + && ((includeSD + && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId)) + && !versionsHd.Contains(lc.ChildId) + && !versions4K.Contains(lc.ChildId)) + || (includeHD + && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId)) + && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight) + && !versions4K.Contains(lc.ChildId)) + || (include4K + && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId)))))))); + } + + // Same reasoning as the resolution filter: a dimension bound is met if any version meets it. if (minWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width >= minWidth); + var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth); + baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id)); } if (filter.MinHeight.HasValue) { - baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight); + var minHeight = filter.MinHeight; + var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight); + baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id)); } + // An upper bound inverts that: it is met only if no version breaches it, since the item's + // resolution is the best its version group offers. if (maxWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width <= maxWidth); + var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id)); } if (filter.MaxHeight.HasValue) { - baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight); + var maxHeight = filter.MaxHeight; + var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight); + baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id)); } if (filter.IsLocked.HasValue) @@ -352,7 +438,7 @@ public sealed partial class BaseItemRepository } else { - baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now); + baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now); } } @@ -366,14 +452,16 @@ public sealed partial class BaseItemRepository p => p.Name, (b, p) => p.Id); + var personTypes = filter.PersonTypes; baseQuery = baseQuery .Where(e => context.PeopleBaseItemMap - .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId))); + .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType)))); } if (!string.IsNullOrWhiteSpace(filter.Person)) { - baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person)); + var personTypes = filter.PersonTypes; + baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType)))); } if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId)) @@ -434,115 +522,67 @@ public sealed partial class BaseItemRepository if (filter.IsLiked.HasValue) { - var isLiked = filter.IsLiked.Value; - baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) == isLiked); - } + var likedIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) + .Select(ud => ud.ItemId); - if (filter.IsFavoriteOrLiked.HasValue) - { - var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value; - baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked); + baseQuery = filter.IsLiked.Value + ? baseQuery.Where(e => likedIds.Contains(e.Id)) + : baseQuery.Where(e => !likedIds.Contains(e.Id)); } - if (filter.IsFavorite.HasValue) + if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue) { - var isFavorite = filter.IsFavorite.Value; - baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite); - } + var favoriteIds = context.UserData + .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) + .Select(ud => ud.ItemId); - if (filter.IsPlayed.HasValue) - { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); - var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet); - - if (hasSeries || hasBoxSet) + if (filter.IsFavoriteOrLiked.HasValue) { - var userId = filter.User!.Id; - var isPlayed = filter.IsPlayed.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; - - // Series: played = at least one episode AND all episodes played; unplayed = otherwise. - IQueryable<Guid> playedSeriesIds = hasSeries - ? context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .Select(g => g.Key) - : Enumerable.Empty<Guid>().AsQueryable(); - - // BoxSet: played = all children played. - IQueryable<Guid> playedBoxSetIds = hasBoxSet - ? GetFullyPlayedFolderIdsQuery( - context, - baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id), - filter.User!) - : Enumerable.Empty<Guid>().AsQueryable(); - - // Non-folder items: check UserData directly - var playedItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.Played) - .Select(ud => ud.ItemId); - - if (isPlayed) - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id))); - } - else - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id))); - } + baseQuery = filter.IsFavoriteOrLiked.Value + ? baseQuery.Where(e => favoriteIds.Contains(e.Id)) + : baseQuery.Where(e => !favoriteIds.Contains(e.Id)); } - else + + if (filter.IsFavorite.HasValue) { - var playedItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.Played) - .Select(ud => ud.ItemId); - var isPlayedItem = filter.IsPlayed.Value; - baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem); + baseQuery = filter.IsFavorite.Value + ? baseQuery.Where(e => favoriteIds.Contains(e.Id)) + : baseQuery.Where(e => !favoriteIds.Contains(e.Id)); } } + if (filter.IsPlayed.HasValue) + { + var isPlayedFilter = BuildIsPlayedFilter(context, filter.User!); + + baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not()); + } + if (filter.IsResumable.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); var userId = filter.User!.Id; var isResumable = filter.IsResumable.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; // In-progress user data rows; alternate versions track their own progress. var inProgress = context.UserData .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); - IQueryable<Guid>? resumableSeriesIds = null; - if (hasSeries) - { - // Aggregate per series in a single GROUP BY pass, instead of three full scans. - var seriesEpisodeStats = context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Select(g => new - { - SeriesId = g.Key, - HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)), - HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)), - HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) - }); - - // A series is resumable if it has an in-progress episode, - // or if it has both played and unplayed episodes (partially watched). - resumableSeriesIds = seriesEpisodeStats - .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) - .Select(s => s.SeriesId); - } + // Series and Seasons are resumable when a descendant is in progress, or when they hold both + // played and unplayed descendants (partially watched). Alternate versions keep their own + // progress, so they count towards the in-progress check but not towards the played/unplayed one. + var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!); + var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)); + + // Every other folder kind is a container rather than one continuous piece of media + var resumableFolderTypes = _resumableFolderKinds + .Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind)) + .ToArray(); + var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type)) + .And(BuildHasDescendantFilter(context, inProgressLeafItems) + .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId))) + .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not()))))); if (isResumable) { @@ -550,21 +590,26 @@ public sealed partial class BaseItemRepository // Match each version on its own progress rather than coalescing onto the primary. var inProgressIds = inProgress.Select(ud => ud.ItemId); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) - : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + baseQuery = baseQuery.Where(folderIsResumableFilter + .Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id)))); // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. - baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems - .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) - .Any(s => - inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) - > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) - || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) - == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) - && s.Id.CompareTo(e.Id) < 0))); + // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, + // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by + // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. + // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those. + baseQuery = baseQuery.Where(e => e.IsFolder + || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id)) + || !context.BaseItems + .Where(s => s.Id != e.Id + && inProgressIds.Contains(s.Id) + && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))); } else { @@ -572,17 +617,14 @@ public sealed partial class BaseItemRepository var resumableMovieIds = inProgress .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) - : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not()) + .Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id)))); } } if (filter.ArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); } if (filter.AlbumArtistIds.Length > 0) @@ -613,12 +655,12 @@ public sealed partial class BaseItemRepository if (filter.ExcludeArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); } if (filter.GenreIds.Count > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray()); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds); } if (filter.Genres.Count > 0) @@ -644,7 +686,7 @@ public sealed partial class BaseItemRepository if (filter.StudioIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray()); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds); } if (filter.OfficialRatings.Length > 0) @@ -765,11 +807,16 @@ public sealed partial class BaseItemRepository { // Exclude owned non-extra items from general queries. // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. - // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries - // keep them so the actually-played version can surface instead of collapsing onto the primary. - baseQuery = filter.IsResumable == true - ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null) - : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null); + + // Alternate versions (PrimaryVersionId set) are normally hidden behind their primary, but + // resume queries keep them so the actually-played version can surface instead of collapsing + // onto the primary, and the library scan keeps them so a merged version is not mistaken for + // a new item. + if (filter.IsResumable != true && !filter.IncludeAlternateVersions) + { + baseQuery = ApplyAlternateVersionFiltering(context, baseQuery); + } } if (filter.OwnerIds.Length > 0) @@ -786,104 +833,144 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) { - var lang = filter.HasNoAudioTrackWithLanguage; - var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang)); + var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang); + // A track only an alternate version carries still belongs to the item a caller sees, so the + // item's own streams alone do not decide this. Same for every stream filter below. + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithAudio.Contains(e.Id)) || (e.IsFolder && !foldersWithAudio.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoInternalSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false)); + var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoExternalSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true)); + var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) { - var lang = filter.HasNoSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang)); + var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (filter.HasSubtitles.HasValue) { var hasSubtitles = filter.HasSubtitles.Value; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles()); + var criteria = new HasSubtitles(); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); if (hasSubtitles) { baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + || versionsWithSubtitles.Contains(e.Id))) || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); } else { baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } } if (filter.SubtitleLanguages.Count > 0) { - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages)); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle - && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle + && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithSubtitles.Contains(e.Id))) || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); } if (filter.AudioLanguages.Count > 0) { - var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages)); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages); + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio - && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio + && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithAudio.Contains(e.Id))) || (e.IsFolder && foldersWithAudio.Contains(e.Id))); } if (filter.HasChapterImages.HasValue) { var hasChapterImages = filter.HasChapterImages.Value; - var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages()); + var criteria = new HasChapterImages(); + var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); if (hasChapterImages) { baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null)) + (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null) + || versionsWithChapterImages.Contains(e.Id))) || (e.IsFolder && foldersWithChapterImages.Contains(e.Id))); } else { baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)) + (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null) + && !versionsWithChapterImages.Contains(e.Id)) || (e.IsFolder && !foldersWithChapterImages.Contains(e.Id))); } } @@ -990,17 +1077,6 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds); } - if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) - { - var includeAny = filter.HasAnyProviderIds - .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) - .ToArray(); - if (includeAny.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f))); - } - } - if (filter.HasImdbId.HasValue) { baseQuery = filter.HasImdbId.Value @@ -1022,21 +1098,7 @@ public sealed partial class BaseItemRepository : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName)); } - var queryTopParentIds = filter.TopParentIds; - - if (queryTopParentIds.Length > 0) - { - var includedItemByNameTypes = GetItemByNameTypesInQuery(filter); - var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; - if (enableItemsByName && includedItemByNameTypes.Count > 0) - { - baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value)); - } - else - { - baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); - } - } + baseQuery = ApplyTopParentFiltering(context, baseQuery, filter); if (filter.AncestorIds.Length > 0) { @@ -1044,6 +1106,12 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); } + if (filter.DescendantOfId.HasValue) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value); + baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id)); + } + if (filter.LinkedChildAncestorIds.Length > 0) { // Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids. @@ -1084,6 +1152,7 @@ public sealed partial class BaseItemRepository { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); @@ -1094,6 +1163,9 @@ public sealed partial class BaseItemRepository || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName + // A playlist should be accessible to its owner regardless of allowed tags || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); } @@ -1141,16 +1213,23 @@ public sealed partial class BaseItemRepository : baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder); } + // An extra is owned by the single version of an item it is named after, so an extra on any + // version counts for the item itself + IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds) + => ownerIds.Concat(context.BaseItems + .Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id)) + .Select(version => version.PrimaryVersionId!.Value)); + if (filter.HasSpecialFeature.HasValue) { - var itemsWithExtras = context.BaseItems + var itemsWithExtras = WithPrimaryVersions(context.BaseItems .Where(extra => extra.OwnerId != null && extra.ExtraType != null && extra.ExtraType != BaseItemExtraType.Unknown && extra.ExtraType != BaseItemExtraType.Trailer && extra.ExtraType != BaseItemExtraType.ThemeSong && extra.ExtraType != BaseItemExtraType.ThemeVideo) - .Select(extra => extra.OwnerId!.Value) + .Select(extra => extra.OwnerId!.Value)) .Distinct(); Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id); @@ -1162,9 +1241,9 @@ public sealed partial class BaseItemRepository if (filter.HasTrailer.HasValue) { - var trailerOwnerIds = context.BaseItems + var trailerOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id); @@ -1175,9 +1254,9 @@ public sealed partial class BaseItemRepository if (filter.HasThemeSong.HasValue) { - var themeSongOwnerIds = context.BaseItems + var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id); @@ -1188,9 +1267,9 @@ public sealed partial class BaseItemRepository if (filter.HasThemeVideo.HasValue) { - var themeVideoOwnerIds = context.BaseItems + var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id); @@ -1217,33 +1296,6 @@ public sealed partial class BaseItemRepository } } - if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty()) - { - var adjacentToId = filter.AdjacentTo.Value; - var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault(); - if (targetItem is not null) - { - var targetSortName = targetItem.SortName ?? string.Empty; - - // Fetch both prev and next adjacent items in a single query using Concat (UNION ALL). - var adjacentIds = context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) < 0) - .OrderByDescending(e => e.SortName) - .Select(e => e.Id) - .Take(1) - .Concat( - context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) > 0) - .OrderBy(e => e.SortName) - .Select(e => e.Id) - .Take(1)) - .ToList(); - - adjacentIds.Add(adjacentToId); - baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id)); - } - } - return baseQuery; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 57041276b7..1d2aa21853 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -46,6 +46,31 @@ public sealed partial class BaseItemRepository private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios]; private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre]; + private static readonly BaseItemKind[] _itemByNameKinds = + [ + BaseItemKind.Person, + BaseItemKind.Genre, + BaseItemKind.MusicGenre, + BaseItemKind.MusicArtist, + BaseItemKind.Studio + ]; + + private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes = + [ + (BaseItemKind.Genre, _getGenreValueTypes), + (BaseItemKind.MusicGenre, _getGenreValueTypes), + (BaseItemKind.MusicArtist, _getAllArtistsValueTypes), + (BaseItemKind.Studio, _getStudiosValueTypes) + ]; + + // The only folder kinds whose children form a single viewing sequence, so playback progress on a + // child rolls up to them. Every other folder kind is a container that cannot be resumed. + private static readonly BaseItemKind[] _resumableFolderKinds = + [ + BaseItemKind.Series, + BaseItemKind.Season + ]; + /// <summary> /// Initializes a new instance of the <see cref="BaseItemRepository"/> class. /// </summary> diff --git a/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs new file mode 100644 index 0000000000..0f8a1b9dfe --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Server.Implementations.Item; + +/// <summary> +/// Turns per-type counts into an <see cref="ItemCounts"/>. +/// </summary> +internal static class ItemCountBuilder +{ + /// <summary> + /// Builds the counts of one by-name item. + /// </summary> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="counts">The counted items, by type name. A type may repeat.</param> + /// <returns>The counts.</returns> + public static ItemCounts Build(IItemTypeLookup itemTypeLookup, IEnumerable<(string Type, int Count)> counts) + { + ArgumentNullException.ThrowIfNull(itemTypeLookup); + ArgumentNullException.ThrowIfNull(counts); + + var lookup = itemTypeLookup.BaseItemKindNames; + var result = new ItemCounts(); + + foreach (var (type, count) in counts) + { + // Accumulated rather than assigned: a caller may group by something finer than the + // type and hand the same type over more than once. + if (string.Equals(type, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) + { + result.AlbumCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) + { + result.ArtistCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) + { + result.EpisodeCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) + { + result.MovieCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) + { + result.MusicVideoCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) + { + result.ProgramCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + { + result.SeriesCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + { + result.SongCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + { + result.TrailerCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + { + result.BoxSetCount += count; + } + else if (string.Equals(type, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + { + result.BookCount += count; + } + } + + result.ItemCount = result.TotalItemCount(); + + return result; + } + + /// <summary> + /// Replaces the episode count, which both by-name paths decide separately from the other + /// types because a genre or studio is usually written on the series rather than its episodes. + /// </summary> + /// <param name="counts">The counts to update.</param> + /// <param name="episodeCount">The episode count.</param> + public static void SetEpisodeCount(ItemCounts counts, int episodeCount) + { + ArgumentNullException.ThrowIfNull(counts); + + counts.EpisodeCount = episodeCount; + counts.ItemCount = counts.TotalItemCount(); + } +} diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 604db9f839..942161a176 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -7,6 +7,7 @@ using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Persistence; @@ -125,131 +126,282 @@ public class ItemCountService : IItemCountService /// <inheritdoc /> public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) { - using var context = _dbProvider.CreateDbContext(); + return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id]; + } - var item = context.BaseItems.AsNoTracking() - .Where(e => e.Id == id) - .Select(e => new { e.Name, e.CleanName }) - .FirstOrDefault(); + private static ItemValueType[] GetItemValueTypes(BaseItemKind kind) + => kind switch + { + BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist], + BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre], + BaseItemKind.Studio => [ItemValueType.Studios], + _ => [] + }; + + /// <inheritdoc /> + public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(relatedItemKinds); + ArgumentNullException.ThrowIfNull(accessFilter); - if (item is null) + var result = new Dictionary<Guid, ItemCounts>(); + if (ids.Count == 0) { - return new ItemCounts(); + return result; } - IQueryable<BaseItemEntity> baseQuery; - switch (kind) + using var context = _dbProvider.CreateDbContext(); + + var idsArray = ids as Guid[] ?? ids.ToArray(); + var nameItems = context.BaseItems.AsNoTracking() + .WhereOneOrMany(idsArray, e => e.Id) + .Select(e => new NameItem(e.Id, e.Name, e.CleanName)) + .ToArray(); + + foreach (var id in ids) { - case BaseItemKind.Person: - baseQuery = context.PeopleBaseItemMap - .AsNoTracking() - .Where(m => m.People.Name == item.Name) - .Select(m => m.Item); - break; - case BaseItemKind.MusicArtist: - baseQuery = context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist)) - .Select(ivm => ivm.Item); - break; - case BaseItemKind.Genre: - case BaseItemKind.MusicGenre: - baseQuery = context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && ivm.ItemValue.Type == ItemValueType.Genre) - .Select(ivm => ivm.Item); - break; - case BaseItemKind.Studio: - baseQuery = context.ItemValuesMap - .AsNoTracking() - .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName - && ivm.ItemValue.Type == ItemValueType.Studios) - .Select(ivm => ivm.Item); - break; - case BaseItemKind.Year: - if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) - { - baseQuery = context.BaseItems - .AsNoTracking() - .Where(e => e.ProductionYear == year); - } - else - { - return new ItemCounts(); - } + result[id] = new ItemCounts(); + } - break; - default: - return new ItemCounts(); + if (nameItems.Length == 0) + { + return result; } var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray(); - baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type)); + var related = _queryHelpers.ApplyAccessFiltering( + context, + context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)), + accessFilter); + + var valueTypes = GetItemValueTypes(kind); + if (valueTypes.Length > 0) + { + CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result); + } + else if (kind == BaseItemKind.Person) + { + CountByPersonName(context, related, nameItems, result); + } + else if (kind == BaseItemKind.Year) + { + CountByProductionYear(related, nameItems, result); + } - baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter); + return result; + } - var counts = baseQuery - .GroupBy(x => x.Type) - .Select(x => new { x.Key, Count = x.Count() }) + private void CountByItemValue( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + BaseItemKind kind, + BaseItemKind[] relatedItemKinds, + ItemValueType[] valueTypes, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var cleanNames = nameItems + .Select(n => n.CleanName) + .OfType<string>() + .Distinct(StringComparer.Ordinal) .ToArray(); - var lookup = _itemTypeLookup.BaseItemKindNames; - var result = new ItemCounts(); - var totalCount = 0; - - foreach (var count in counts) + if (cleanNames.Length == 0) { - totalCount += count.Count; + return; + } - if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) - { - result.AlbumCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) - { - result.ArtistCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) - { - result.EpisodeCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) - { - result.MovieCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) - { - result.MusicVideoCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) - { - result.ProgramCount = count.Count; - } - else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + var grouped = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue) + .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id }) + .GroupBy(x => new { x.CleanValue, x.Type }) + .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + var byCleanName = grouped + .GroupBy(g => g.CleanValue, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds) + && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal)) + ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames) + : null; + + foreach (var nameItem in nameItems) + { + if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts)) { - result.SeriesCount = count.Count; + continue; } - else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + + var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts); + + if (episodeRollUp is not null) { - result.SongCount = count.Count; + var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName); + + // Episodes of a tagged series count towards it even when untagged themselves, and + // a tagged episode of a tagged series must not be counted a second time. + var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries; + ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount); } - else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + + result[nameItem.Id] = itemCounts; + } + } + + private void CountByPersonName( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var names = nameItems + .Select(n => n.Name) + .OfType<string>() + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (names.Length == 0) + { + return; + } + + var grouped = context.PeopleBaseItemMap.AsNoTracking() + .WhereOneOrMany(names, m => m.People.Name) + .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id }) + .GroupBy(x => new { x.Name, x.Type }) + // A person can be credited on one item more than once, in different roles. + .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result); + } + + private void CountByProductionYear( + IQueryable<BaseItemEntity> related, + NameItem[] nameItems, + Dictionary<Guid, ItemCounts> result) + { + var years = new List<int>(); + foreach (var nameItem in nameItems) + { + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && !years.Contains(year)) { - result.TrailerCount = count.Count; + years.Add(year); } - else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + } + + if (years.Count == 0) + { + return; + } + + // No join, so no row can be reached twice and a plain count is the distinct count. + var grouped = related + .Where(e => e.ProductionYear != null) + .WhereOneOrMany(years, e => e.ProductionYear!.Value) + .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type }) + .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() }) + .ToArray(); + + var byYear = grouped + .GroupBy(g => g.Year) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray()); + + foreach (var nameItem in nameItems) + { + if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year) + && byYear.TryGetValue(year, out var counts)) { - result.BoxSetCount = count.Count; + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); } - else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + } + } + + private void ApplyGroupedCounts( + NameItem[] nameItems, + Func<NameItem, string?> keySelector, + IEnumerable<(string Key, string Type, int Count)> grouped, + Dictionary<Guid, ItemCounts> result) + { + var byKey = grouped + .GroupBy(g => g.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal); + + foreach (var nameItem in nameItems) + { + var key = keySelector(nameItem); + if (key is not null && byKey.TryGetValue(key, out var counts)) { - result.BookCount = count.Count; + result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts); } } + } + + private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds) + => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre + && relatedItemKinds.Contains(BaseItemKind.Episode) + && relatedItemKinds.Contains(BaseItemKind.Series); + + private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeriesByCleanName( + JellyfinDbContext context, + IQueryable<BaseItemEntity> related, + ItemValueType[] valueTypes, + string[] cleanNames) + { + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + var taggedValues = context.ItemValuesMap.AsNoTracking() + .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); + + // The series carrying each clean name. Distinct, because one item can be mapped to the + // same clean name once per value type. + var taggedSeries = taggedValues + .Join( + related.Where(e => e.Type == seriesTypeName), + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .Distinct(); + + var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null); + + var episodesOfTaggedSeries = taggedSeries + .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); - result.ItemCount = totalCount; + // Episodes that carry the clean name themselves *and* belong to a series carrying it. The + // roll-up already counts those, so they have to come off the directly tagged ones. + var taggedEpisodesOfTaggedSeries = taggedValues + .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId }) + .Join( + taggedSeries, + e => new { e.CleanValue, SeriesId = e.SeriesId!.Value }, + s => new { s.CleanValue, s.SeriesId }, + (e, s) => new { e.CleanValue, e.Id }) + .GroupBy(x => x.CleanValue) + .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() }) + .ToArray(); + + var taggedLookup = taggedEpisodesOfTaggedSeries + .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal); + + // Every clean name in taggedLookup came from an episode of a tagged series, so it always + // has a row in episodesOfTaggedSeries too - no second merge pass is needed. + var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringComparer.Ordinal); + foreach (var entry in episodesOfTaggedSeries) + { + result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue)); + } return result; } @@ -257,19 +409,21 @@ public class ItemCountService : IItemCountService /// <inheritdoc/> public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { + ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); - return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); + return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id)); } /// <inheritdoc/> public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId) { + ArgumentNullException.ThrowIfNull(filter); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return baseQuery.Count(); } @@ -280,10 +434,23 @@ public class ItemCountService : IItemCountService ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); } + private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId) + { + var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId]; + var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray(); + + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(descendantIds, b => b.Id) + .Where(DescendantQueryHelper.IsCountableLeaf); + + return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); + } + /// <inheritdoc/> public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) { @@ -291,16 +458,17 @@ public class ItemCountService : IItemCountService ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId); + var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray(); var baseQuery = dbContext.BaseItems - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .WhereOneOrMany(allDescendantIds, b => b.Id) + .Where(DescendantQueryHelper.IsCountableLeaf); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); } /// <inheritdoc/> - public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) { ArgumentNullException.ThrowIfNull(parentIds); @@ -313,22 +481,58 @@ public class ItemCountService : IItemCountService var parentIdsArray = parentIds.ToArray(); - var hierarchicalCounts = dbContext.BaseItems - .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value)) + var includeVirtual = user is null || user.DisplayMissingEpisodes; + + var accessibleItems = dbContext.BaseItems.AsNoTracking(); + if (user is null) + { + // Access filtering is what would otherwise drop an alternate version, and a child count + // must not report a title twice just because no user was passed in. + accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem); + } + else + { + accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user)); + } + + var hierarchicalCounts = accessibleItems + .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value) .GroupBy(b => b.ParentId!.Value) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); + // An episode is a child of its season even when it is not stored under one: with a flat + // structure ParentId points at the series, so counting by ParentId alone leaves the season + // empty and counts its episodes towards the series instead. + var seasonCounts = accessibleItems + .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value) + .GroupBy(b => b.SeasonId!.Value) + .Select(g => new { SeasonId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeasonId, x => x.Count); + + // A linked child counts only when the item it points at is one the user may open. var linkedCounts = dbContext.LinkedChildren - .Where(lc => parentIdsArray.Contains(lc.ParentId)) - .GroupBy(lc => lc.ParentId) + .WhereOneOrMany(parentIdsArray, lc => lc.ParentId) + .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId) + .GroupBy(parentId => parentId) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); + var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual); + var result = new Dictionary<Guid, int>(); foreach (var parentId in parentIds) { - var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0); + if (mergedChildCounts.TryGetValue(parentId, out var mergedCount)) + { + result[parentId] = mergedCount; + continue; + } + + var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0) + + seasonCounts.GetValueOrDefault(parentId, 0); var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount; @@ -337,6 +541,58 @@ public class ItemCountService : IItemCountService return result; } + private static Dictionary<Guid, int> GetMergedChildCounts( + JellyfinDbContext dbContext, + IQueryable<BaseItemEntity> accessibleItems, + IReadOnlyList<Guid> parentIds, + bool includeVirtual) + { + var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) + .Where(group => group.Value.Count > 1) + .ToArray(); + + if (mergedGroups.Length == 0) + { + return []; + } + + // Only merged folders. + var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); + var children = accessibleItems + .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(memberIds, b => b.ParentId!.Value) + .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) + .ToArray() + .Concat(accessibleItems + .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(memberIds, b => b.SeasonId!.Value) + .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey }) + .ToArray()) + .GroupBy(b => b.ParentId) + .ToDictionary( + g => g.Key, + g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey) + ? b.Id.ToString("N", CultureInfo.InvariantCulture) + : b.PresentationUniqueKey).ToArray()); + + var result = new Dictionary<Guid, int>(); + foreach (var (parentId, members) in mergedGroups) + { + var childKeys = new HashSet<string>(StringComparer.Ordinal); + foreach (var member in members) + { + if (children.TryGetValue(member, out var keys)) + { + childKeys.UnionWith(keys); + } + } + + result[parentId] = childKeys.Count; + } + + return result; + } + /// <inheritdoc/> public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user) { @@ -349,16 +605,19 @@ public class ItemCountService : IItemCountService } using var dbContext = _dbProvider.CreateDbContext(); - var folderIdsArray = folderIds.ToArray(); var filter = new InternalItemsQuery(user); var userId = user.Id; + // Merged series and seasons are stored as one row per folder-item sharing a presentation key. + var groups = GetPresentationKeyGroups(dbContext, folderIds); + var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray(); + var leafItems = dbContext.BaseItems - .Where(b => !b.IsFolder && !b.IsVirtualItem); + .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); + .Select(DescendantQueryHelper.PlayedStateBy(userId)); var ancestorLeaves = dbContext.AncestorIds .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) @@ -394,7 +653,7 @@ public class ItemCountService : IItemCountService b => b.Id, (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - var results = ancestorLeaves + var countsByFolder = ancestorLeaves .Union(linkedLeaves) .Union(linkedFolderLeaves) .GroupBy(x => x.FolderId) @@ -406,13 +665,77 @@ public class ItemCountService : IItemCountService }) .ToDictionary(x => x.FolderId, x => (x.Played, x.Total)); + var results = new Dictionary<Guid, (int Played, int Total)>(); + foreach (var (folderId, members) in groups) + { + var played = 0; + var total = 0; + + // Members of a group are distinct folders, so their leaves cannot overlap. + foreach (var member in members) + { + if (countsByFolder.TryGetValue(member, out var counts)) + { + played += counts.Played; + total += counts.Total; + } + } + + if (total > 0 || played > 0) + { + results[folderId] = (played, total); + } + } + return results; } + private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds) + { + var requested = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(folderIds, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToArray(); + + var keys = requested + .Select(e => e.PresentationUniqueKey) + .Where(key => !string.IsNullOrEmpty(key)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + // Every item that is not merged carries a key derived from its own id, so in the common case + // each group resolves back to the single folder that was asked for. + var membersByKey = keys.Length == 0 + ? [] + : dbContext.BaseItems + .AsNoTracking() + .Where(e => e.IsFolder) + .WhereOneOrMany(keys, e => e.PresentationUniqueKey!) + .Select(e => new { e.Id, Key = e.PresentationUniqueKey! }) + .ToArray() + .GroupBy(e => e.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal); + + var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey); + var groups = new Dictionary<Guid, List<Guid>>(); + foreach (var folderId in folderIds) + { + groups[folderId] = keyById.TryGetValue(folderId, out var key) + && !string.IsNullOrEmpty(key) + && membersByKey.TryGetValue(key, out var members) + && members.Count > 0 + ? members + : [folderId]; + } + + return groups; + } + private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId) { var result = query - .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) + .Select(DescendantQueryHelper.IsPlayedBy(userId)) .GroupBy(_ => 1) .OrderBy(g => g.Key) .Select(g => new @@ -424,4 +747,12 @@ public class ItemCountService : IItemCountService return result is null ? (0, 0) : (result.Played, result.Total); } + + /// <summary> + /// A by-name item, reduced to the three columns the counting keys off. + /// </summary> + /// <param name="Id">The id of the by-name item.</param> + /// <param name="Name">The name of the by-name item.</param> + /// <param name="CleanName">The cleaned name of the by-name item.</param> + private sealed record NameItem(Guid Id, string? Name, string? CleanName); } diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index b10f7c527e..a7b0b1c1fc 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -68,16 +69,24 @@ public class ItemPersistenceService : IItemPersistenceService // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a // single parameter (json_each) rather than one SQL variable per id, which would otherwise // overflow SQLite's variable limit when deleting many items at once (e.g. migrations). - var ownerIds = descendantIds.ToArray(); - var extraIds = context.BaseItems - .Where(e => e.OwnerId.HasValue) - .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value) - .Select(e => e.Id) - .ToArray(); - - foreach (var extraId in extraIds) + var frontier = descendantIds.ToArray(); + while (frontier.Length > 0) { - descendantIds.Add(extraId); + var ownedIds = context.BaseItems + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(frontier, e => e.OwnerId!.Value) + .Select(e => e.Id) + .ToArray(); + + var childIds = context.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(frontier, e => e.ParentId!.Value) + .Select(e => e.Id) + .ToArray(); + + // Only ids that were not already known become the next frontier, so ownership cycles + // terminate instead of looping forever. + frontier = [.. ownedIds.Concat(childIds).Where(e => descendantIds.Add(e))]; } var relatedItems = descendantIds.ToArray(); @@ -136,13 +145,13 @@ public class ItemPersistenceService : IItemPersistenceService context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete(); context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete(); + var peopleIds = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); + context.Peoples.WhereOneOrMany(peopleIds, e => e.Id).Where(e => !e.BaseItems!.Any()).ExecuteDelete(); context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.SaveChanges(); transaction.Commit(); @@ -176,14 +185,6 @@ public class ItemPersistenceService : IItemPersistenceService var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - if (!await context.BaseItems - .AnyAsync(bi => bi.Id == item.Id, cancellationToken) - .ConfigureAwait(false)) - { - _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); - return; - } - await context.BaseItemImageInfos .Where(e => e.ItemId == item.Id) .ExecuteDeleteAsync(cancellationToken) @@ -193,7 +194,26 @@ public class ItemPersistenceService : IItemPersistenceService .AddRangeAsync(images, cancellationToken) .ConfigureAwait(false); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + try + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException) + { + // Checking that the item exists before writing leaves a gap a scan can delete it + // through, turning the insert into a foreign key violation that fails the whole + // refresh instead of the no-op intended here. Let the insert be the check: it is the + // only point at which the answer cannot go stale. Nothing is orphaned by the delete + // above, because deleting the item cascades to its images anyway. + if (await context.BaseItems + .AnyAsync(bi => bi.Id == item.Id, cancellationToken) + .ConfigureAwait(false)) + { + throw; + } + + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id); + } } } @@ -257,23 +277,19 @@ public class ItemPersistenceService : IItemPersistenceService using var transaction = context.Database.BeginTransaction(); 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 existingItems = context.BaseItems.WhereOneOrMany(ids, e => e.Id).Select(f => f.Id).ToHashSet(); foreach (var item in tuples) { var entity = BaseItemMapper.Map(item.Item, _appHost); entity.TopParentId = item.TopParent?.Id; - if (!existingItems.Any(e => e == entity.Id)) + if (!existingItems.Contains(entity.Id)) { context.BaseItems.Add(entity); } else { - context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - if (entity.Images is { Count: > 0 }) { context.BaseItemImageInfos.AddRange(entity.Images); @@ -314,12 +330,14 @@ public class ItemPersistenceService : IItemPersistenceService }).ToArray(); context.ItemValues.AddRange(missingItemValues); - var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); + var itemValuesStore = existingValues + .Concat(missingItemValues) + .ToDictionary(e => (e.Type, e.Value)); var valueMap = itemValueMaps - .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray())) + .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray())) .ToArray(); - var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); + var mappedValues = context.ItemValuesMap.WhereOneOrMany(ids, e => e.ItemId).ToList(); foreach (var item in valueMap) { @@ -401,6 +419,15 @@ public class ItemPersistenceService : IItemPersistenceService } } + // Owned rows of updated items are rewritten wholesale; cleared in one statement per table. + if (existingItems.Count > 0) + { + var updatedIds = existingItems.ToArray(); + context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + } + context.SaveChanges(); var folderIds = tuples @@ -428,106 +455,144 @@ public class ItemPersistenceService : IItemPersistenceService foreach (var item in tuples) { - if (item.Item is Folder folder) + // A container that was never hydrated cannot be used to rewrite its links: its empty + // array means "unknown", so clearing the stored rows would silently empty the item. + if (item.Item is Folder { LinkedChildrenLoaded: false }) { - var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>(); - if (folder.LinkedChildren.Length > 0) + continue; + } + + if (item.Item is Folder or Video + && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks) + && existingLinks.Count > 0) + { + // A video only owns its alternate version links; any other link on that parent is + // written by the folder branch below and must survive. + var staleLinks = item.Item is Folder + ? existingLinks + : existingLinks + .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion) + .ToList(); + + if (staleLinks.Count > 0) { + context.LinkedChildren.RemoveRange(staleLinks); + } + } + } + + context.SaveChanges(); + + // A LinkedChild's ItemId is only a cache. + var cachedChildIds = tuples + .Select(t => t.Item) + .OfType<Folder>() + .Where(f => f.LinkedChildrenLoaded) + .SelectMany(f => f.LinkedChildren) + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .Distinct() + .ToList(); + + var knownChildIds = cachedChildIds.Count > 0 + ? context.BaseItems + .WhereOneOrMany(cachedChildIds, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; + + foreach (var item in tuples) + { + if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0) + { #pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data - var pathsToResolve = folder.LinkedChildren - .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path)) - .Select(lc => lc.Path) - .Distinct() - .ToList(); + var pathsToResolve = folder.LinkedChildren + .Where(lc => !string.IsNullOrEmpty(lc.Path) + && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value))) + .Select(lc => lc.Path) + .Distinct() + .ToList(); - var pathToIdMap = pathsToResolve.Count > 0 - ? context.BaseItems - .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) - .Select(e => new { e.Path, e.Id }) - .GroupBy(e => e.Path!) - .ToDictionary(g => g.Key, g => g.First().Id) - : []; + var pathToIdMap = pathsToResolve.Count > 0 + ? context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id) + : []; - var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); - foreach (var linkedChild in folder.LinkedChildren) + var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); + foreach (var linkedChild in folder.LinkedChildren) + { + var childItemId = linkedChild.ItemId; + if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value)) { - var childItemId = linkedChild.ItemId; - if (!childItemId.HasValue || childItemId.Value.IsEmpty()) + if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) { - if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) - { - childItemId = resolvedId; - } + childItemId = resolvedId; } -#pragma warning restore CS0618 - - if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty()) { - resolvedChildren.Add((linkedChild, childItemId.Value)); + childItemId = libraryItemId; } } +#pragma warning restore CS0618 + + if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + { + resolvedChildren.Add((linkedChild, childItemId.Value)); + } + } + // Playlists may legitimately contain the same item multiple times (e.g. a song repeated + // in an .m3u file). Every other container type keeps a single entry per child. + var isPlaylist = folder is Playlist; + if (!isPlaylist) + { resolvedChildren = resolvedChildren .GroupBy(c => c.ChildId) .Select(g => g.Last()) .ToList(); + } - var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList(); - var existingChildIds = childIdsToCheck.Count > 0 - ? context.BaseItems - .Where(e => childIdsToCheck.Contains(e.Id)) - .Select(e => e.Id) - .ToHashSet() - : []; - - var isPlaylist = folder is Playlist; - var sortOrder = 0; - foreach (var (linkedChild, childId) in resolvedChildren) - { - if (!existingChildIds.Contains(childId)) - { - _logger.LogWarning( - "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database", - item.Item.Name, - item.Item.Id, - childId); - continue; - } - - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) - { - context.LinkedChildren.Add(new LinkedChildEntity() - { - ParentId = item.Item.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)linkedChild.Type, - SortOrder = isPlaylist ? sortOrder : null - }); - } - else - { - existingLink.SortOrder = isPlaylist ? sortOrder : null; - existingLink.ChildType = (DbLinkedChildType)linkedChild.Type; - existingLinkedChildren.Remove(existingLink); - } + var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .WhereOneOrMany(childIdsToCheck, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; - sortOrder++; + var sortOrder = 0; + foreach (var (linkedChild, childId) in resolvedChildren) + { + if (!existingChildIds.Contains(childId)) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics + _logger.LogWarning( + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database", + item.Item.Name, + item.Item.Id, + childId, + linkedChild.Path ?? "unknown"); +#pragma warning restore CS0618 + continue; } - } - if (existingLinkedChildren.Count > 0) - { - context.LinkedChildren.RemoveRange(existingLinkedChildren); + context.LinkedChildren.Add(new LinkedChildEntity() + { + ParentId = item.Item.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)linkedChild.Type, + SortOrder = sortOrder + }); + + sortOrder++; } } if (item.Item is Video video) { - var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>()) - .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3) - .ToList(); - var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>(); if (video.LocalAlternateVersions.Length > 0) @@ -577,7 +642,7 @@ public class ItemPersistenceService : IItemPersistenceService .ToHashSet() : []; - int sortOrder = 0; + var sortOrder = 0; foreach (var (childId, childType) in newLinkedChildren) { if (!existingChildIds.Contains(childId)) @@ -590,36 +655,59 @@ public class ItemPersistenceService : IItemPersistenceService continue; } - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) + context.LinkedChildren.Add(new LinkedChildEntity { - context.LinkedChildren.Add(new LinkedChildEntity - { - ParentId = video.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)childType, - SortOrder = sortOrder - }); - } - else + ParentId = video.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)childType, + SortOrder = sortOrder + }); + + sortOrder++; + } + + var linkedChildIds = newLinkedChildren + .Select(c => c.ChildId) + // A video listed among its own versions would be pointed at itself. + .Where(childId => existingChildIds.Contains(childId) && !childId.Equals(video.Id)) + .Where(childId => !childId.Equals(video.PrimaryVersionId)) + .ToList(); + if (linkedChildIds.Count > 0) + { + var demotedChildren = context.BaseItems + .Where(e => linkedChildIds.Contains(e.Id) + && (e.PrimaryVersionId == null || e.PrimaryVersionId != video.Id)) + .ToList(); + + foreach (var child in demotedChildren) { - existingLink.ChildType = (DbLinkedChildType)childType; - existingLink.SortOrder = sortOrder; - existingLinkedChildren.Remove(existingLink); + child.PrimaryVersionId = video.Id; + + // Mirrors Video.CreatePresentationUniqueKey, so presentation-key grouping + // collapses the version onto its primary as well. + child.PresentationUniqueKey = video.Id.ToString("N", CultureInfo.InvariantCulture); } - sortOrder++; + if (demotedChildren.Count > 0) + { + _logger.LogInformation( + "Set PrimaryVersionId on {Count} alternate versions of video {VideoName} ({VideoId})", + demotedChildren.Count, + video.Name, + video.Id); + } } - if (existingLinkedChildren.Count > 0) + // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned; + var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id); + if (previousLinkedChildren is { Count: > 0 }) { - var orphanedLocalVersionIds = existingLinkedChildren - .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion) + var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet(); + var orphanedLocalVersionIds = previousLinkedChildren + .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId)) .Select(e => e.ChildId) .ToList(); - context.LinkedChildren.RemoveRange(existingLinkedChildren); - if (orphanedLocalVersionIds.Count > 0) { var orphanedItems = context.BaseItems diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 5e5ce320a5..de112d7aa4 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Persistence; using Microsoft.EntityFrameworkCore; @@ -60,30 +61,50 @@ public class LinkedChildrenService : ILinkedChildrenService } /// <inheritdoc/> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + if (itemIds.Count == 0) + { + return new HashSet<Guid>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + + return dbContext.LinkedChildren + .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion + || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion) + .WhereOneOrMany(itemIds, lc => lc.ParentId) + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + } + + /// <inheritdoc/> public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames) { using var dbContext = _dbProvider.CreateDbContext(); - var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray(); + var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray(); + var cleanValues = cleanNames.Select(x => x.Clean).ToArray(); + var artists = dbContext.BaseItems .AsNoTracking() .Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) - .Where(e => lowerNames.Contains(e.Name!.ToLower())) + .Where(e => cleanValues.Contains(e.CleanName)) .ToArray(); var lookup = artists - .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase) + .GroupBy(e => e.CleanName!) .ToDictionary( g => g.Key, - g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(), - StringComparer.OrdinalIgnoreCase); + g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); - var result = new Dictionary<string, MusicArtist[]>(artistNames.Count); - foreach (var name in artistNames) + var result = new Dictionary<string, MusicArtist[]>(cleanNames.Length); + foreach (var (original, clean) in cleanNames) { - if (lookup.TryGetValue(name, out var artistArray)) + if (lookup.TryGetValue(clean, out var artistArray)) { - result[name] = artistArray; + result[original] = artistArray; } } @@ -159,12 +180,16 @@ public class LinkedChildrenService : ILinkedChildrenService if (existingLink is null) { + var nextSortOrder = (context.LinkedChildren + .Where(lc => lc.ParentId == parentId) + .Max(lc => (int?)lc.SortOrder) ?? -1) + 1; + context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity { ParentId = parentId, ChildId = childId, ChildType = dbChildType, - SortOrder = null + SortOrder = nextSortOrder }); } else diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 7fa33c8639..a25629132b 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -172,8 +172,7 @@ public class MediaStreamRepository : IMediaStreamRepository if (!string.IsNullOrEmpty(dto.Language)) { - var culture = _localization.FindLanguageInfo(dto.Language); - dto.LocalizedLanguage = culture?.DisplayName; + dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language); } if (dto.Type is MediaStreamType.Audio) diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs index 725b4cfaac..897fb98cbb 100644 --- a/Jellyfin.Server.Implementations/Item/NextUpService.cs +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -95,10 +95,10 @@ public class NextUpService : INextUpService .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter); - // Use lightweight projection + client-side grouping to avoid correlated scalar subquery + // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery // per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault. var allPlayedLite = lastWatchedBase .Select(e => new @@ -110,15 +110,11 @@ public class NextUpService : INextUpService }) .ToList(); - var lastWatchedInfo = new Dictionary<string, Guid>(); - foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey)) - { - var lastWatched = group - .OrderByDescending(e => e.ParentIndexNumber) - .ThenByDescending(e => e.IndexNumber) - .First(); - lastWatchedInfo[group.Key!] = lastWatched.Id; - } + var lastWatchedInfo = allPlayedLite + .OrderByDescending(e => e.ParentIndexNumber) + .ThenByDescending(e => e.IndexNumber) + .DistinctBy(e => e.SeriesPresentationUniqueKey) + .ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id); Dictionary<string, Guid> lastWatchedByDateInfo = new(); if (includeWatchedForRewatching) @@ -133,22 +129,30 @@ public class NextUpService : INextUpService // Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation. // SelectMany on UserData with a correlated Where would translate to APPLY, // which SQLite does not support. + // Access filtering leaves only primaries in the base query, but a play can be recorded + // against any version, so each row is attributed to its group's primary before the join. + var playedByGroupPrimary = context.UserData + .AsNoTracking() + .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) + .Where(ud => ud.Played) + .Join( + context.BaseItems.AsNoTracking(), + ud => ud.ItemId, + bi => bi.Id, + (ud, bi) => new { ud.UserId, ItemId = bi.PrimaryVersionId ?? bi.Id, ud.LastPlayedDate }); + var playedWithDates = lastWatchedByDateBase .Join( - context.UserData - .AsNoTracking() - .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) - .Where(ud => ud.Played), + playedByGroupPrimary, e => new { UserId = userId, ItemId = e.Id }, ud => new { ud.UserId, ud.ItemId }, (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }) .ToList(); - foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey)) - { - var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First(); - lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId; - } + lastWatchedByDateInfo = playedWithDates + .OrderByDescending(x => x.LastPlayedDate) + .DistinctBy(x => x.SeriesPresentationUniqueKey) + .ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId); } var allLastWatchedIds = lastWatchedInfo.Values @@ -203,7 +207,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsUnplayedBy(userId)); allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter); var allUnplayedCandidates = allUnplayedBase .Select(e => new @@ -251,7 +255,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter); var allPlayedCandidates = allPlayedBase .Select(e => new diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index aac85d0131..00b10e44a9 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -29,19 +29,30 @@ public static class OrderMapper /// <returns>Func to be executed later for sorting query.</returns> public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext) { + if (sortBy == ItemSortBy.DatePlayed) + { + // An item's played date is the newest of its own progress and that of its alternate versions, + // which track progress under their own ids. Matching both in one predicate ORs them together, + // which no index can serve: the user's whole UserData table gets scanned per sorted row. + // Two indexed lookups combined by MAX cost a seek each instead. + var userData = query.User is null + ? jellyfinDbContext.UserData + : jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id); + + return e => userData + .Where(w => w.ItemId == e.Id) + .Select(w => w.LastPlayedDate) + .Concat(userData + .Where(w => w.Item!.PrimaryVersionId == e.Id) + .Select(w => w.LastPlayedDate)) + .Max(); + } + return (sortBy, query.User) switch { (ItemSortBy.AirTime, _) => e => e.SortName, (ItemSortBy.Runtime, _) => e => e.RunTimeTicks, (ItemSortBy.Random, _) => e => EF.Functions.Random(), - (ItemSortBy.DatePlayed, not null) => e => - jellyfinDbContext.UserData - .Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)) - .Max(f => f.LastPlayedDate), - (ItemSortBy.DatePlayed, null) => e => - jellyfinDbContext.UserData - .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id) - .Max(f => f.LastPlayedDate), (ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount, (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false, (ItemSortBy.IsFolder, _) => e => e.IsFolder, @@ -57,7 +68,7 @@ public static class OrderMapper (ItemSortBy.DateCreated, _) => e => e.DateCreated, (ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null), (ItemSortBy.StartDate, _) => e => e.StartDate, - (ItemSortBy.Name, _) => e => e.SortName, + (ItemSortBy.Name, _) => e => e.CleanName, (ItemSortBy.CommunityRating, _) => e => e.CommunityRating, (ItemSortBy.ProductionYear, _) => e => e.ProductionYear, (ItemSortBy.CriticRating, _) => e => e.CriticRating, diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index eb87b525fe..fcddc09ad9 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item; /// </summary> /// <param name="dbProvider">Efcore Factory.</param> /// <param name="itemTypeLookup">Items lookup service.</param> +/// <param name="queryHelpers">Shared item query helpers.</param> /// <remarks> /// Initializes a new instance of the <see cref="PeopleRepository"/> class. /// </remarks> -public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository +public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository { private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider; @@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { using var context = _dbProvider.CreateDbContext(); var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter); + int? distinctNameCount = null; // Include PeopleBaseItemMap if (!filter.ItemId.IsEmpty()) { dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId)) - .OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder) + .OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder)) .ThenBy(e => e.PersonType) .ThenBy(e => e.Name); } @@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { // The Peoples table has one row per (Name, PersonType), so the same person can // appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per - // name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the - // grouping key so case-only duplicates collapse together. - var representativeIds = dbQuery - .GroupBy(e => e.Name.ToLower()) - .Select(g => g.Min(e => e.Id)); - dbQuery = context.Peoples.AsNoTracking() - .Where(p => representativeIds.Contains(p.Id)) - .OrderBy(e => e.Name); + // name so /Persons doesn't return the same BaseItem id repeatedly, keeping the + // lowest id per lowercased name so case-only duplicates collapse together. + var candidates = dbQuery; + dbQuery = candidates + .Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id)) + .OrderBy(e => e.Name.ToLower()); + + if (filter.EnableTotalRecordCount) + { + distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count(); + } + } + + var count = 0; + if (filter.EnableTotalRecordCount) + { + count = distinctNameCount ?? dbQuery.Count(); } - var count = dbQuery.Count(); if (filter.StartIndex.HasValue && filter.StartIndex > 0) { dbQuery = dbQuery.Skip(filter.StartIndex.Value); @@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { StartIndex = filter.StartIndex ?? 0, TotalRecordCount = count, - Items = dbQuery.AsEnumerable().Select(Map).ToArray(), + Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(), }; } @@ -79,7 +89,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter) { using var context = _dbProvider.CreateDbContext(); - var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct(); + + IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter) + .Select(e => e.Name) + .Distinct() + .OrderBy(e => e); if (filter.StartIndex.HasValue && filter.StartIndex > 0) { @@ -88,7 +102,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (filter.Limit > 0) { - dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit); + dbQuery = dbQuery.Take(filter.Limit); } return dbQuery.ToArray(); @@ -103,38 +117,109 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I person.Role = person.Role?.Trim() ?? string.Empty; } - // multiple metadata providers can provide the _same_ person; dedupe case-insensitively. - people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); - var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); + // Project the values every comparison below needs once, so neither the case folding nor the + // enum formatting is repeated per candidate. + var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant())); + + // multiple metadata providers can provide the _same_ credit; dedupe case-insensitively. + // The role is part of the key because one person can hold several credits of the same type + // on an item, e.g. a Writer credited for both the Novel and the Screenplay. + var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray(); + + var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray(); using var context = _dbProvider.CreateDbContext(); + var existingMaps = context.PeopleBaseItemMap + .AsNoTracking() + .Include(e => e.People) + .Where(e => e.ItemId == itemId) + .ToList(); + + // Most library scans refresh unchanged local metadata. Avoid opening a write + // transaction when the item's people mappings, order and roles are unchanged. + var incomingCredits = distinctCredits + .Select((credit, index) => new + { + Key = (credit.LoweredName, credit.PersonType, credit.LoweredRole), + Role = credit.Person.Role, + ListOrder = index, + SortOrder = credit.Person.SortOrder + }) + .ToDictionary(e => e.Key); + var mappingsAreUnchanged = existingMaps.Count == incomingCredits.Count + && existingMaps.All(map => + incomingCredits.TryGetValue( + (map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), + out var incoming) + && map.ListOrder == incoming.ListOrder + && map.SortOrder == incoming.SortOrder + && string.Equals(map.Role ?? string.Empty, incoming.Role, StringComparison.OrdinalIgnoreCase)); + + if (mappingsAreUnchanged) + { + return; + } + using var transaction = context.Database.BeginTransaction(); - var existingPersons = context.Peoples.Select(e => new + // The fast-path snapshot was read before acquiring the write transaction. Reload + // tracked mappings inside it so a concurrent refresh cannot leave stale credits. + existingMaps = context.PeopleBaseItemMap + .Include(e => e.People) + .Where(e => e.ItemId == itemId) + .ToList(); + + // Query each person type separately so SQLite can use IX_Peoples_NameLower. + // Combining the two fields into `lower(Name) || '-' || PersonType` forces a full + // scan of Peoples for every media item, which is prohibitive during a large import. + var existingPersons = new List<People>(); + foreach (var personTypeGroup in distinctPersons.GroupBy(e => e.PersonType, StringComparer.Ordinal)) { - item = e, - SelectionKey = e.Name.ToLower() + "-" + e.PersonType - }) - .Where(p => personKeys.Contains(p.SelectionKey)) - .Select(f => f.item) - .ToArray(); + var names = personTypeGroup + .Select(e => e.LoweredName) + .ToArray(); + + existingPersons.AddRange(context.Peoples + .Where(e => e.PersonType == personTypeGroup.Key && names.Contains(e.Name.ToLower())) + .ToArray()); + } + + var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet(); - var toAdd = people - .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString())) - .Select(Map); + var toAdd = distinctPersons + .Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType))) + .Select(e => Map(e.Person)) + .ToArray(); context.Peoples.AddRange(toAdd); context.SaveChanges(); - var personsEntities = toAdd.Concat(existingPersons).ToArray(); + // The Peoples table can hold case-only duplicates, so keep the first match per key just as + // the previous First() lookup did. + var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>(); + foreach (var entity in toAdd.Concat(existingPersons)) + { + personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity); + } - var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList(); + var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>(); + foreach (var map in existingMaps) + { + existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map); + } var listOrder = 0; - foreach (var person in people) + foreach (var credit in distinctCredits) { - var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString()); - var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); - if (existingMap is null) + var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)]; + if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap)) + { + // Update the order for existing mappings + existingMap.ListOrder = listOrder; + existingMap.SortOrder = credit.Person.SortOrder; + // person mapping already exists so remove from list + existingMaps.Remove(existingMap); + } + else { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() { @@ -143,35 +228,59 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I People = null!, PeopleId = entityPerson.Id, ListOrder = listOrder, - SortOrder = person.SortOrder, - Role = person.Role + SortOrder = credit.Person.SortOrder, + Role = credit.Person.Role }); } - else - { - // Update the order for existing mappings - existingMap.ListOrder = listOrder; - existingMap.SortOrder = person.SortOrder; - // person mapping already exists so remove from list - existingMaps.Remove(existingMap); - } listOrder++; } + var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray(); context.PeopleBaseItemMap.RemoveRange(existingMaps); context.SaveChanges(); + + // Nothing else ever deletes a credit row, so one left without a single mapping outlives the + // credit it stood for: it keeps a person of that name off the dead-person sweep, which only + // sees items no credit names, and keeps the name in every by-name list. That is how a credit + // a provider dropped, or one a broken provider result invented, becomes impossible to clean up. + DeleteCreditsWithoutMapping(context, droppedCredits); + + context.SaveChanges(); transaction.Commit(); } /// <inheritdoc/> + public int DeleteOrphanedCredits() + { + using var context = _dbProvider.CreateDbContext(); + + return DeleteCreditsWithoutMapping(context, null); + } + + // A null candidate list sweeps every credit, anything else only the ones just unmapped. + private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList<Guid>? candidates) + { + if (candidates is not null && candidates.Count == 0) + { + return 0; + } + + var credits = candidates is null + ? context.Peoples.AsQueryable() + : context.Peoples.WhereOneOrMany(candidates, e => e.Id); + + return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete(); + } + + /// <inheritdoc/> public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes) { using var context = _dbProvider.CreateDbContext(); var query = context.PeopleBaseItemMap .AsNoTracking() - .Where(m => itemIds.Contains(m.ItemId)); + .WhereOneOrMany(itemIds, m => m.ItemId); if (personTypes.Count > 0) { @@ -201,9 +310,66 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I return result; } - private PersonInfo Map(People people) + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds) + { + using var context = _dbProvider.CreateDbContext(); + var rows = context.PeopleBaseItemMap + .AsNoTracking() + .WhereOneOrMany(itemIds, m => m.ItemId) + .OrderBy(m => m.ListOrder) + .Select(m => new + { + m.ItemId, + m.Role, + m.SortOrder, + m.People.Id, + m.People.Name, + m.People.PersonType + }) + .ToList(); + + var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) + { + var people = new List<PersonInfo>(); + foreach (var row in group) + { + var personInfo = new PersonInfo + { + ItemId = row.ItemId, + Id = row.Id, + Name = row.Name, + Role = row.Role, + SortOrder = row.SortOrder + }; + if (Enum.TryParse<PersonKind>(row.PersonType, out var kind)) + { + personInfo.Type = kind; + } + + people.Add(personInfo); + } + + result[group.Key] = people; + } + + return result; + } + + private IEnumerable<PersonInfo> MapCredits(People people) + { + var mappings = people.BaseItems; + if (mappings is null || mappings.Count == 0) + { + return [Map(people, null)]; + } + + return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m)); + } + + private PersonInfo Map(People people, PeopleBaseItemMap? mapping) { - var mapping = people.BaseItems?.FirstOrDefault(); var personInfo = new PersonInfo() { Id = people.Id, @@ -236,18 +402,34 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (filter.User is not null && filter.IsFavorite.HasValue) { var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; - var oldQuery = query; + var userId = filter.User.Id; + var isFavorite = filter.IsFavorite.Value; + var favoriteItemIds = context.UserData + .Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite) + .Select(u => u.ItemId); - query = context.UserData - .Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id)) - .Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person) - .Distinct() - .AsNoTracking(); + var favoriteNames = context.BaseItems + .Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id)) + .Select(b => b.Name); + + query = query.Where(e => favoriteNames.Contains(e.Name)); + } + + if (filter.AccessFilter is not null) + { + // Keep only people credited on at least one item the user can see. + var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter); + query = query.Where(e => context.PeopleBaseItemMap + .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId))); } if (!filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId))); + var itemId = filter.ItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(itemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } if (filter.ParentId != null) @@ -257,7 +439,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (!filter.AppearsInItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId))); + var appearsInItemId = filter.AppearsInItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(appearsInItemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList(); diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index 13c7895f83..0989ce84ba 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -87,8 +87,9 @@ public static class StorageHelper /// </summary> private static string ResolvePath(string path) { - var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); - var current = Path.DirectorySeparatorChar.ToString(); + var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString(); + var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = root; foreach (var part in parts) { current = Path.Combine(current, part); diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs index 92e2bb4fa7..7c46ef7721 100644 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data; @@ -9,6 +10,7 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Implementations.Users; @@ -20,6 +22,7 @@ public sealed class DeviceAccessHost : IHostedService private readonly IUserManager _userManager; private readonly IDeviceManager _deviceManager; private readonly ISessionManager _sessionManager; + private readonly ILogger<DeviceAccessHost> _logger; /// <summary> /// Initializes a new instance of the <see cref="DeviceAccessHost"/> class. @@ -27,11 +30,17 @@ public sealed class DeviceAccessHost : IHostedService /// <param name="userManager">The <see cref="IUserManager"/>.</param> /// <param name="deviceManager">The <see cref="IDeviceManager"/>.</param> /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param> - public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager) + /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param> + public DeviceAccessHost( + IUserManager userManager, + IDeviceManager deviceManager, + ISessionManager sessionManager, + ILogger<DeviceAccessHost> logger) { _userManager = userManager; _deviceManager = deviceManager; _sessionManager = sessionManager; + _logger = logger; } /// <inheritdoc /> @@ -53,9 +62,18 @@ public sealed class DeviceAccessHost : IHostedService private async void OnUserUpdated(object? sender, GenericEventArgs<User> e) { var user = e.Argument; - if (!user.HasPermission(PermissionKind.EnableAllDevices)) + + // This handler is async void, so an escaping exception would terminate the process. + try + { + if (!user.HasPermission(PermissionKind.EnableAllDevices)) + { + await UpdateDeviceAccess(user).ConfigureAwait(false); + } + } + catch (Exception ex) { - await UpdateDeviceAccess(user).ConfigureAwait(false); + _logger.LogError(ex, "Error updating device access for user {UserId}", user.Id); } } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 583f29f94f..b15f6b98b2 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -225,17 +225,8 @@ namespace Jellyfin.Server.Implementations.Users ?? throw new ResourceNotFoundException(nameof(user.Id)); dbContext.Entry(dbUser).CurrentValues.SetValues(user); - dbUser.Permissions.Clear(); - foreach (var permission in user.Permissions) - { - dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value)); - } - - dbUser.Preferences.Clear(); - foreach (var preference in user.Preferences) - { - dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value)); - } + SyncPermissions(dbUser, user.Permissions); + SyncPreferences(dbUser, user.Preferences); dbUser.AccessSchedules.Clear(); foreach (var accessSchedule in user.AccessSchedules) @@ -269,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users } } + private static void SyncPermissions(User dbUser, ICollection<Permission> source) + { + var incoming = new Dictionary<PermissionKind, bool>(); + foreach (var permission in source) + { + incoming[permission.Kind] = permission.Value; + } + + foreach (var existing in dbUser.Permissions) + { + if (incoming.Remove(existing.Kind, out var value)) + { + // EF only marks the row modified if the value actually differs, so an update that + // touches nothing but the user row - a session activity stamp - writes no children. + existing.Value = value; + } + else + { + dbUser.Permissions.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Permissions.Add(new Permission(kind, value)); + } + } + + private static void SyncPreferences(User dbUser, ICollection<Preference> source) + { + var incoming = new Dictionary<PreferenceKind, string>(); + foreach (var preference in source) + { + incoming[preference.Kind] = preference.Value; + } + + foreach (var existing in dbUser.Preferences) + { + if (incoming.Remove(existing.Kind, out var value)) + { + existing.Value = value; + } + else + { + dbUser.Preferences.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Preferences.Add(new Preference(kind, value)); + } + } + internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext) { // TODO: Remove after user item data is migrated. @@ -616,6 +661,12 @@ namespace Jellyfin.Server.Implementations.Users .SetProperty(f => f.LastActivityDate, date) .SetProperty(f => f.LastLoginDate, date)) .ConfigureAwait(false); + + // ExecuteUpdateAsync bypasses the change tracker, so keep the + // returned entity in sync. Otherwise SessionManager.LogSessionActivity + // saves this (stale) entity in full and reverts LastLoginDate. + user.LastActivityDate = date; + user.LastLoginDate = date; } await dbContext.Users @@ -796,14 +847,16 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { + User user; using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = UserQuery(dbContext) + user = await UserQuery(dbContext) .AsTracking() - .FirstOrDefault(u => u.Id.Equals(userId)) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false) ?? throw new ArgumentException("No user exists with given Id!"); // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" @@ -868,6 +921,10 @@ namespace Jellyfin.Server.Implementations.Users await dbContext.SaveChangesAsync().ConfigureAwait(false); } } + + var eventArgs = new UserUpdatedEventArgs(user); + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); + OnUserUpdated?.Invoke(this, eventArgs); } /// <inheritdoc/> @@ -883,8 +940,20 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + // Remove the tracked profile image loaded from the database instead of the + // detached instance on the passed in user. That instance can carry a stale, + // never-persisted (temporary) key, which makes EF Core throw when it is marked + // for deletion, leaving the profile image impossible to clear or replace. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false); + if (dbUser?.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } user.ProfileImage = null; @@ -893,7 +962,7 @@ namespace Jellyfin.Server.Implementations.Users internal static void ThrowIfInvalidUsername(string name) { - if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) + if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal)) { return; } diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index a10be76e05..a5b6bc4604 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -183,7 +183,13 @@ internal class JellyfinMigrationService } } - public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider) + /// <summary> + /// Runs all pending migrations of the requested stage. + /// </summary> + /// <param name="stage">The stage to migrate.</param> + /// <param name="serviceProvider">The service provider handed to the migrations.</param> + /// <returns>A value indicating whether at least one migration has been applied.</returns> + public async Task<bool> MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider) { var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}."); ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? []; @@ -193,10 +199,15 @@ internal class JellyfinMigrationService { var historyRepository = dbContext.GetService<IHistoryRepository>(); var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>(); - (string Key, IInternalMigration Migration)[] migrations = []; + var completedMigrations = 0; + string? lastMigrationKey = null; - do - { // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply. + while (true) + { + // A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the + // state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations + // with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before + // that point is stale, so only ever run the next migration and then work out the pending set again. var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); var pendingCodeMigrations = migrationStage .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) @@ -212,73 +223,88 @@ internal class JellyfinMigrationService } (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; - logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); - migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + if (pendingMigrations.Length == 0) + { + break; + } - var migrationIndex = 0; - foreach (var item in migrations) + if (completedMigrations == 0) { - // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. - SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length)); - var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); - try - { - migrationLogger.LogInformation("Perform migration {Name}", item.Key); - await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); - migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); - } - catch (Exception ex) - { - migrationLogger.LogCritical("Error: {Error}", ex.Message); - migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); + logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage); + } + + var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First(); + if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely."); + } + + lastMigrationKey = item.Key; + + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length)); + var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); + try + { + migrationLogger.LogInformation("Perform migration {Name}", item.Key); + await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); + migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); + } + catch (Exception ex) + { + migrationLogger.LogCritical("Error: {Error}", ex.Message); + migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); - if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + { + if (_backupKey.LibraryDb is not null) { - if (_backupKey.LibraryDb is not null) + migrationLogger.LogInformation("Attempt to rollback librarydb."); + try { - migrationLogger.LogInformation("Attempt to rollback librarydb."); - try - { - var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); - File.Move(_backupKey.LibraryDb, libraryDbPath, true); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); - } + var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); + File.Move(_backupKey.LibraryDb, libraryDbPath, true); } - - if (_backupKey.JellyfinDb is not null) + catch (Exception inner) { - migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); - try - { - await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); - } + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); } + } - if (_backupKey.FullBackup is not null) + if (_backupKey.JellyfinDb is not null) + { + migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); + try { - migrationLogger.LogInformation("Attempt to rollback from backup."); - try - { - await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); - } + await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); } } - throw; + if (_backupKey.FullBackup is not null) + { + migrationLogger.LogInformation("Attempt to rollback from backup."); + try + { + await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); + } + } } + + throw; } - } while (migrations.Length != 0); + + completedMigrations++; + } + + return completedMigrations > 0; } } @@ -427,10 +453,10 @@ internal class JellyfinMigrationService private class InternalCodeMigration : IInternalMigration { private readonly CodeMigration _codeMigration; - private readonly IServiceProvider? _serviceProvider; + private readonly IServiceProvider _serviceProvider; private JellyfinDbContext _dbContext; - public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext) + public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider serviceProvider, JellyfinDbContext dbContext) { _codeMigration = codeMigration; _serviceProvider = serviceProvider; diff --git a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs deleted file mode 100644 index 1545ebdc8e..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Playlists; - -namespace Jellyfin.Server.Migrations.Routines; - -/// <summary> -/// Remove duplicate playlist entries. -/// </summary> -#pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")] -internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine -#pragma warning restore CS0618 // Type or member is obsolete -{ - private readonly ILibraryManager _libraryManager; - private readonly IPlaylistManager _playlistManager; - - public RemoveDuplicatePlaylistChildren( - ILibraryManager libraryManager, - IPlaylistManager playlistManager) - { - _libraryManager = libraryManager; - _playlistManager = playlistManager; - } - - /// <inheritdoc/> - public void Perform() - { - var playlists = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Playlist] - }) - .Cast<Playlist>() - .Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty)) - .ToArray(); - - if (playlists.Length > 0) - { - foreach (var playlist in playlists) - { - var linkedChildren = playlist.LinkedChildren; - if (linkedChildren.Length > 0) - { - var newLinkedChildren = linkedChildren - .Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty)) - .Concat(linkedChildren - .Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty)) - .DistinctBy(c => c.ItemId)) - .ToArray(); - playlist.LinkedChildren = newLinkedChildren; - playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); - _playlistManager.SavePlaylistFile(playlist); - } - } - } - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs index 8a0a1741f1..291de23b2e 100644 --- a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs @@ -29,7 +29,7 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine private readonly IDbContextFactory<JellyfinDbContext> _provider; public MigrateLibraryUserData( - IStartupLogger<MigrateLibraryDb> startupLogger, + IStartupLogger<MigrateLibraryUserData> startupLogger, IDbContextFactory<JellyfinDbContext> provider, IServerApplicationPaths paths) { diff --git a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs index 502763ac09..c8ee44a670 100644 --- a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs +++ b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs @@ -24,7 +24,7 @@ internal class ReseedFolderFlag : IAsyncMigrationRoutine private readonly IDbContextFactory<JellyfinDbContext> _provider; public ReseedFolderFlag( - IStartupLogger<MigrateLibraryDb> startupLogger, + IStartupLogger<ReseedFolderFlag> startupLogger, IDbContextFactory<JellyfinDbContext> provider, IServerApplicationPaths paths) { diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index c433c1d043..8f239007d8 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -22,6 +22,11 @@ namespace Jellyfin.Server.Migrations.Routines; [JellyfinMigrationBackup(JellyfinDb = true)] internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { + private const int ParseProgressLogStep = 25_000; + private const int FileCheckProgressLogStep = 10_000; + private const int ResolveProgressLogStep = 10_000; + private const int DeleteProgressLogStep = 25; + private readonly ILogger<MigrateLinkedChildren> _logger; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly ILibraryManager _libraryManager; @@ -63,7 +68,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var itemsWithData = context.BaseItems .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type))) - .Select(b => new { b.Id, b.Data, b.Type }) + .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder }) .ToList(); _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count); @@ -74,9 +79,17 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .GroupBy(b => b.Path!) .ToDictionary(g => g.Key, g => g.First().Id); + // Needed to tell a stale cached ItemId apart from one that still points at a real item. + var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet(); + + var playlistParentIds = itemsWithData + .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist") + .Select(b => b.Id) + .ToHashSet(); + + var droppedChildren = 0; var linkedChildrenToAdd = new List<LinkedChildEntity>(); var processedCount = 0; - const int progressLogStep = 1000; var totalItems = itemsWithData.Count; foreach (var item in itemsWithData) @@ -86,7 +99,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine continue; } - if (processedCount > 0 && processedCount % progressLogStep == 0) + if (processedCount > 0 && processedCount % ParseProgressLogStep == 0) { _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems); } @@ -100,7 +113,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Handle Video alternate versions if (isVideo) { - ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd); + ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd); } // Handle LinkedChildren (for containers and other items) @@ -110,46 +123,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine continue; } - var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist"; + // Legacy entries may hold a path relative to the container that holds them, so the + // container's own location has to be a real path, not a virtual one. + var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path); + var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath); var sortOrder = 0; foreach (var childElement in linkedChildrenElement.EnumerateArray()) { - Guid? childId = null; - if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds); + if (!childId.HasValue) { + droppedChildren++; + _logger.LogWarning( + "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}", + item.Id, + GetStringProperty(childElement, "ItemId") ?? "none", + GetStringProperty(childElement, "Path") ?? "none"); continue; } @@ -175,7 +164,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine ParentId = item.Id, ChildId = childId.Value, ChildType = childType, - SortOrder = isPlaylist ? sortOrder : null + SortOrder = sortOrder }); sortOrder++; @@ -197,23 +186,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(lc => new { lc.ParentId, lc.ChildId }) .ToHashSet(); + // A playlist may list the same child more than once, so it cannot be keyed by + // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which + // keeps the routine re-runnable without collapsing repeated entries. + var populatedParentIds = context.LinkedChildren + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + var toInsert = linkedChildrenToAdd - .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) + .Where(lc => playlistParentIds.Contains(lc.ParentId) + ? !populatedParentIds.Contains(lc.ParentId) + : !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) .ToList(); if (toInsert.Count > 0) { - // Deduplicate by composite key (ParentId, ChildId) + // Every container type other than a playlist keeps a single entry per child. // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other - toInsert = toInsert - .OrderBy(lc => lc.ChildType switch - { - LinkedChildType.LocalAlternateVersion => 0, - LinkedChildType.LinkedAlternateVersion => 1, - _ => 2 - }) - .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) - .ToList(); + toInsert = + [ + .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)), + .. toInsert + .Where(lc => !playlistParentIds.Contains(lc.ParentId)) + .OrderBy(lc => lc.ChildType switch + { + LinkedChildType.LocalAlternateVersion => 0, + LinkedChildType.LinkedAlternateVersion => 1, + _ => 2 + }) + .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) + ]; var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); var existingChildIds = context.BaseItems @@ -267,7 +270,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("No LinkedChildren data found to migrate."); } - _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); + _logger.LogInformation( + "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.", + processedCount, + droppedChildren); CleanupWrongTypeAlternateVersions(context); CleanupOrphanedAlternateVersionBaseItems(context); @@ -309,11 +315,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count); - var itemsToDelete = wrongTypeChildIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(wrongTypeChildIds, "wrong-type alternate version items"); _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted); } @@ -340,11 +342,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count); - var itemsToDelete = orphanedVersionIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(orphanedVersionIds, "orphaned alternate version BaseItems"); _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted); } @@ -369,11 +367,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count); - var itemsToDelete = orphanedIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(orphanedIds, "items from deleted libraries"); _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted); } @@ -418,9 +412,30 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var internalMetadataPath = _appPaths.InternalMetadataPath; + // An item outside every library location is normally left over from a removed media path, but + // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the + // first container start, for example). Only act on it while every location is readable. + var canRemoveUnrootedItems = inaccessiblePaths.Count == 0; + var skippedUnrootedItems = 0; + var staleIds = new List<Guid>(); + var checkedCount = 0; + _logger.LogInformation("Checking {Total} items for missing files.", itemsWithPaths.Count); + foreach (var item in itemsWithPaths) { + // A miss on offline storage can block for the mount timeout, so report while scanning. + if (checkedCount > 0 && checkedCount % FileCheckProgressLogStep == 0) + { + _logger.LogInformation( + "Checking for missing files: {Checked}/{Total} items, {Stale} stale so far.", + checkedCount, + itemsWithPaths.Count, + staleIds.Count); + } + + checkedCount++; + // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths var path = _appHost.ExpandVirtualPath(item.Path!); @@ -436,6 +451,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Directory check covers BDMV/DVD items whose Path points to a folder if (!File.Exists(path) && !Directory.Exists(path)) { + _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path); staleIds.Add(item.Id); } } @@ -443,12 +459,28 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { // Item is not under ANY library location (accessible or not) — // it's orphaned from all libraries (e.g. media path was removed from config) - staleIds.Add(item.Id); + if (canRemoveUnrootedItems) + { + _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path); + staleIds.Add(item.Id); + } + else + { + skippedUnrootedItems++; + } } // Otherwise: item is under an inaccessible location — skip (storage may be offline) } + if (skippedUnrootedItems > 0) + { + _logger.LogWarning( + "Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.", + skippedUnrootedItems, + inaccessiblePaths.Count); + } + if (staleIds.Count == 0) { _logger.LogInformation("No stale items found."); @@ -457,16 +489,47 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count); - var itemsToDelete = staleIds - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - var deleted = DeleteItems(itemsToDelete!); + var deleted = ResolveAndDeleteItems(staleIds, "items with missing files"); _logger.LogInformation("Removed {Count} stale items.", deleted); } - private int DeleteItems(IReadOnlyCollection<BaseItem> items) + private int ResolveAndDeleteItems(IReadOnlyCollection<Guid> ids, string description) + { + if (ids.Count == 0) + { + return 0; + } + + return DeleteItems(ResolveItems(ids, description), description); + } + + private List<BaseItem> ResolveItems(IReadOnlyCollection<Guid> ids, string description) + { + // Each lookup is a separate repository read; cached ones are fast, so this only reports + // once a set is large enough for the reads to add up to a noticeable stretch. + var items = new List<BaseItem>(ids.Count); + var processed = 0; + foreach (var id in ids) + { + if (processed > 0 && processed % ResolveProgressLogStep == 0) + { + _logger.LogInformation("Loading {Description}: {Processed}/{Total} items", description, processed, ids.Count); + } + + processed++; + + var item = _libraryManager.GetItemById(id); + if (item is not null) + { + items.Add(item); + } + } + + return items; + } + + private int DeleteItems(IReadOnlyCollection<BaseItem> items, string description) { if (items.Count == 0) { @@ -475,8 +538,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false }; var deleted = 0; + var processed = 0; foreach (var item in items) { + if (processed > 0 && processed % DeleteProgressLogStep == 0) + { + _logger.LogInformation("Removing {Description}: {Processed}/{Total} items", description, processed, items.Count); + } + + processed++; + try { _libraryManager.DeleteItem(item, options); @@ -518,18 +589,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine orphanedLinkedChildren.AddRange(orphanedByParent); } - // Remove all orphaned records - var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList(); + // Remove all orphaned records. Both queries can return the same row, and a playlist may hold + // several rows for one child, so the position is what identifies an entry here. + var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList(); context.LinkedChildren.RemoveRange(distinctOrphaned); context.SaveChanges(); _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count); } + /// <summary> + /// Resolves the item a legacy LinkedChild entry points at. + /// </summary> + private static Guid? ResolveChildId( + JsonElement childElement, + string? containingFolderPath, + Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds) + { + // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value + // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore + // fall through to the path, or the entry is lost even though its file is still in the library. + if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId)) + { + return itemId; + } + + var path = GetStringProperty(childElement, "Path"); + if (!string.IsNullOrEmpty(path)) + { + if (pathToIdMap.TryGetValue(path, out var idByPath)) + { + return idByPath; + } + + // 10.x resolved entries relative to the container that holds them. + if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath)) + { + string? absolutePath = null; + try + { + absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path)); + } + catch (ArgumentException) + { + // Malformed path, nothing to resolve. + } + + if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath)) + { + return idByAbsolutePath; + } + } + } + + if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId)) + { + return libraryItemId; + } + + return null; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value) + { + value = Guid.Empty; + var raw = GetStringProperty(element, propertyName); + + return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty(); + } + private void ProcessVideoAlternateVersions( JsonElement root, Guid parentId, Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds, List<LinkedChildEntity> linkedChildrenToAdd) { int sortOrder = 0; @@ -582,45 +721,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray()) { - Guid? childId = null; - - // Try to get ItemId - if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - // Try to get from Path if ItemId not available - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - // Try LibraryItemId as fallback - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds); + if (!childId.HasValue) { _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId); continue; diff --git a/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs new file mode 100644 index 0000000000..e9eefc20dc --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs @@ -0,0 +1,113 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to recompute the SortName of all items that have a forced sort name. +/// </summary> +[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class RefreshForcedSortNames : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RefreshForcedSortNames> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerConfigurationManager _configurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> + /// <param name="configurationManager">The server configuration manager providing the sort rules.</param> + public RefreshForcedSortNames( + IStartupLogger<RefreshForcedSortNames> logger, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerConfigurationManager configurationManager) + { + _logger = logger; + _dbProvider = dbProvider; + _configurationManager = configurationManager; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var configuration = _configurationManager.Configuration; + // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules. + var personType = typeof(Person).ToString(); + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName)); + _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records); + + var processedInPartition = 0; + + await foreach (var item in context.BaseItems + .Where(b => !string.IsNullOrEmpty(b.ForcedSortName)) + .OrderBy(e => e.Id) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal); + var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration); + if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'", + item.Id, + item.SortName, + newSortName); + item.SortName = newSortName; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}", + itemCount, + records, + sw.Elapsed); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs new file mode 100644 index 0000000000..16ac6cb5e5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Restores playlist entries from playlist.xml for playlists that lost all of their children. +/// </summary> +[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))] +internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine +{ + private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist"; + private const string PlaylistFileName = "playlist.xml"; + + private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerApplicationHost _appHost; + + public RestorePlaylistChildrenFromMetadata( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerApplicationHost appHost) + { + _logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>(); + _dbProvider = dbProvider; + _appHost = appHost; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + var playlists = context.BaseItems + .Where(b => b.Type == PlaylistTypeName && b.Path != null) + .Select(b => new { b.Id, b.Name, b.Path }) + .ToList(); + + if (playlists.Count == 0) + { + return; + } + + var childCountByPlaylist = context.LinkedChildren + .Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(g => g.ParentId, g => g.Count); + + var pathToIdMap = context.BaseItems + .Where(b => b.Path != null) + .Select(b => new { b.Id, b.Path }) + .GroupBy(b => b.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + var restoredPlaylists = 0; + var restoredEntries = 0; + + foreach (var playlist in playlists) + { + // Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml. + // A playlist that is itself a file (.m3u and friends) is re-read by the library scan. + var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!); + var metadataPath = Path.Combine(playlistPath, PlaylistFileName); + if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath)) + { + continue; + } + + var storedPaths = ReadEntryPaths(metadataPath, playlist.Id); + if (storedPaths.Count == 0) + { + continue; + } + + var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id); + if (childCount > 0) + { + // Merging into a playlist that still has entries would resurrect anything the user + // removed while the metadata file was not rewritten, and there is no way to tell the + // two apart. Report the mismatch instead so it can be checked by hand. + if (storedPaths.Count > childCount) + { + _logger.LogWarning( + "Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.", + playlist.Name, + playlist.Id, + childCount, + metadataPath, + storedPaths.Count); + } + + continue; + } + + var sortOrder = 0; + foreach (var storedPath in storedPaths) + { + if (!pathToIdMap.TryGetValue(storedPath, out var childId)) + { + _logger.LogWarning( + "Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.", + storedPath, + playlist.Name); + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = playlist.Id, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + + sortOrder++; + } + + if (sortOrder > 0) + { + restoredPlaylists++; + restoredEntries += sortOrder; + _logger.LogInformation( + "Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.", + sortOrder, + playlist.Name, + playlist.Id, + metadataPath); + } + } + + if (restoredEntries > 0) + { + context.SaveChanges(); + _logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists); + } + } + + private List<string> ReadEntryPaths(string metadataPath, Guid playlistId) + { + var paths = new List<string>(); + var settings = new XmlReaderSettings + { + IgnoreComments = true, + IgnoreWhitespace = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit + }; + + try + { + using var reader = XmlReader.Create(metadataPath, settings); + var inEntry = false; + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + { + continue; + } + + if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal)) + { + inEntry = true; + } + else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal)) + { + inEntry = false; + var value = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(value)) + { + paths.Add(value.Trim()); + } + } + } + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId); + } + + return paths; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs new file mode 100644 index 0000000000..0e50ec2f47 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library. +/// </summary> +[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="dbProvider">The database context factory.</param> + public RecomputeSeriesPresentationKey( + IStartupLogger<RecomputeSeriesPresentationKey> logger, + ILibraryManager libraryManager, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series] + }).OfType<Series>().ToArray(); + + _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); + + const int ProgressInterval = 250; + var sw = Stopwatch.StartNew(); + var newSeriesKeys = new Dictionary<Guid, string>(); + var processed = 0; + var updated = 0; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var item in series) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (++processed % ProgressInterval == 0) + { + _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed); + } + + var newKey = item.CreatePresentationUniqueKey(); + newSeriesKeys[item.Id] = newKey; + + if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + // Write only the changed column instead of re-persisting the whole item. + var id = item.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so + // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than + // by the old key: that key can be shared by every library holding the series, so matching + // on it would drag the other libraries' children along. + await dbContext.BaseItems + .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}", + updated, + series.Length, + updatedSeasons, + sw.Elapsed); + } + } + + private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken) + { + // A season's own key embeds its series' key, so it goes stale with it. + var seasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season] + }).OfType<Season>().ToArray(); + + var updated = 0; + + foreach (var season in seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Without an index number the season keeps the base key, which carries no series key at all. + if (!season.IndexNumber.HasValue + || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey)) + { + continue; + } + + // Mirrors Season.CreatePresentationUniqueKey. + var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + var id = season.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + return updated; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs new file mode 100644 index 0000000000..8eefbdb63a --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Moves the views whose id used to be derived from their localized name onto their name independent id. +/// </summary> +[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; + private readonly IFileSystem _fileSystem; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="configurationManager">The server configuration manager.</param> + /// <param name="fileSystem">The file system.</param> + /// <param name="dbProvider">The database context factory.</param> + public ConsolidateLocalizedUserViews( + IStartupLogger<ConsolidateLocalizedUserViews> logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + // The Live TV view is the one that hurts: every channel and program is parented to it, so a + // translation update or a change of UI culture used to leave them behind under a view nothing + // looks up any more. + var views = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.UserView] + }).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray(); + + if (views.Length == 0) + { + return; + } + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var group in views.GroupBy(view => view.ViewType!.Value)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var viewType = group.Key; + var folderName = _fileSystem.GetValidFilename(viewType.ToString()); + var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName); + + // Only the views created for a view type as a whole are named after it. The per user and + // per parent ones get a folder of their own, and carry no children to lose. Match on the + // folder rather than the whole path so a metadata directory that has since moved still + // lines up. + var candidates = group + .Where(view => !string.IsNullOrEmpty(view.Path) + && string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (candidates.Length == 0) + { + continue; + } + + // Mirrors LibraryManager.GetNamedView(name, viewType, sortName). + var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); + + var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray(); + if (stale.Length == 0) + { + continue; + } + + await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task ConsolidateAsync( + JellyfinDbContext dbContext, + CollectionType viewType, + string path, + Guid canonicalId, + IReadOnlyList<UserView> candidates, + IReadOnlyList<UserView> stale, + CancellationToken cancellationToken) + { + var staleIds = stale.Select(view => view.Id).ToArray(); + Guid? newParentId = canonicalId; + var sourceId = Guid.Empty; + + if (!candidates.Any(view => view.Id.Equals(canonicalId))) + { + // Whichever of the old views the items ended up under is the one worth keeping, so give the + // canonical id a copy of it. + var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false); + sourceId = source.Id; + + _libraryManager.CreateItem( + new UserView + { + Path = path, + Id = canonicalId, + DateCreated = source.DateCreated, + DateModified = source.DateModified, + Name = source.Name, + ViewType = viewType, + ForcedSortName = source.ForcedSortName + }, + null); + } + + var reparented = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.BaseItems + .Where(e => e.TopParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.TopParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false); + await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false); + await MoveRemainingReferencesAsync(dbContext, newParentId, staleIds, cancellationToken).ConfigureAwait(false); + + // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last. + await dbContext.BaseItems + .WhereOneOrMany(staleIds, e => e.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + _logger.LogInformation( + "Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}", + reparented, + staleIds.Length, + viewType, + canonicalId); + } + + private static async Task MoveRemainingReferencesAsync( + JellyfinDbContext dbContext, + Guid? canonicalId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + await dbContext.BaseItems + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(staleIds, e => e.OwnerId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.OwnerId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + // Keyed by (ParentId, SortOrder), so these cannot be repointed onto the canonical view + // without risking a collision, and a view listing linked children is meaningless anyway. + await dbContext.LinkedChildren + .WhereOneOrMany(staleIds, e => e.ParentId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + await dbContext.LinkedChildren + .WhereOneOrMany(staleIds, e => e.ChildId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + private async Task<UserView> PickSourceAsync( + JellyfinDbContext dbContext, + IReadOnlyList<UserView> stale, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + var childCounts = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .GroupBy(e => e.ParentId!.Value) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken) + .ConfigureAwait(false); + + return stale + .OrderByDescending(view => childCounts.GetValueOrDefault(view.Id)) + .ThenBy(view => view.DateCreated) + .First(); + } + + private static async Task MoveUserSettingsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + Guid sourceId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + // Everything below is keyed by the view's id, and a view holding no children still holds the + // ordering it was given and whether it was hidden. Only the view that was promoted can hand + // those over - the rest would collide on the one row per user, item and client - so the others + // are dropped instead. + var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray(); + + if (!sourceId.Equals(Guid.Empty)) + { + var moved = new[] { sourceId }; + + await dbContext.DisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.ItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.CustomItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + } + + if (dropped.Length > 0) + { + await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + var stale = staleIds.ToHashSet(); + var preferences = await dbContext.Preferences + .Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var changed = false; + + foreach (var preference in preferences) + { + var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var rewritten = new List<string>(values.Length); + var seen = new HashSet<Guid>(); + var touched = false; + + foreach (var value in values) + { + // Clients write these in both the dashed and the plain form, so compare them parsed. + if (!Guid.TryParse(value, out var parsed)) + { + rewritten.Add(value); + continue; + } + + var isStale = stale.Contains(parsed); + if (isStale) + { + parsed = canonicalId; + touched = true; + } + + // The same view can be listed twice once both of its ids point at the same place. + if (!seen.Add(parsed)) + { + continue; + } + + rewritten.Add(isStale + ? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture) + : value); + } + + if (!touched) + { + continue; + } + + preference.Value = string.Join(',', rewritten); + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task MoveAncestorsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + // Ancestry recorded against items that no longer exist is dead weight. + var items = await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .Where(e => dbContext.BaseItems.Any(item => item.Id.Equals(e.ItemId))) + .Select(e => e.ItemId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + if (items.Count == 0) + { + return; + } + + // The pair is the primary key, so anything already recorded against the canonical view stays put. + var existing = await dbContext.AncestorIds + .Where(e => e.ParentItemId.Equals(canonicalId)) + .Select(e => e.ItemId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var itemId in items.Except(existing)) + { + dbContext.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = canonicalId, + Item = null!, + ParentItem = null! + }); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs new file mode 100644 index 0000000000..e665725ced --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260831100000_EnableLocalSimilarityProviders.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Migrations.Stages; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Enables the local similarity providers on libraries that predate the similar items settings. +/// </summary> +[JellyfinMigration("2026-08-31T10:00:00", nameof(EnableLocalSimilarityProviders), Stage = JellyfinMigrationStageTypes.AppInitialisation)] +internal class EnableLocalSimilarityProviders : IAsyncMigrationRoutine +{ + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly ILogger _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="EnableLocalSimilarityProviders"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="startupLogger">The startup logger for Startup UI integration.</param> + /// <param name="logger">The logger.</param> + public EnableLocalSimilarityProviders( + ILibraryManager libraryManager, + IProviderManager providerManager, + IStartupLogger<EnableLocalSimilarityProviders> startupLogger, + ILogger<EnableLocalSimilarityProviders> logger) + { + _libraryManager = libraryManager; + _providerManager = providerManager; + _logger = startupLogger.With(logger); + } + + /// <inheritdoc /> + public Task PerformAsync(CancellationToken cancellationToken) + { + // Libraries created before similar items became configurable have an empty provider list, + // which the library editor renders as "everything unchecked" instead of falling back to the + // defaults it uses for new libraries. Seed the local providers so they stay enabled. + var localProvidersByType = GetLocalProvidersByItemType(); + if (localProvidersByType.Count == 0) + { + return Task.CompletedTask; + } + + foreach (var virtualFolder in _libraryManager.GetVirtualFolders(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + EnableLocalProviders(virtualFolder, localProvidersByType); + } + + return Task.CompletedTask; + } + + private void EnableLocalProviders(VirtualFolderInfo virtualFolder, Dictionary<string, string[]> localProvidersByType) + { + var options = virtualFolder.LibraryOptions; + if (options?.TypeOptions is null || options.TypeOptions.Length == 0) + { + return; + } + + // Some virtual folders don't have a proper item id. + if (!Guid.TryParse(virtualFolder.ItemId, out var folderId)) + { + return; + } + + var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId); + if (collectionFolder is null) + { + _logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId); + return; + } + + var changed = false; + foreach (var typeOptions in options.TypeOptions) + { + changed |= EnableLocalProviders(typeOptions, localProvidersByType, virtualFolder.Name); + } + + if (changed) + { + collectionFolder.UpdateLibraryOptions(options); + } + } + + private bool EnableLocalProviders(TypeOptions typeOptions, Dictionary<string, string[]> localProvidersByType, string libraryName) + { + if (typeOptions.Type is null || !localProvidersByType.TryGetValue(typeOptions.Type, out var localProviders)) + { + return false; + } + + var enabled = typeOptions.SimilarItemProviders ?? []; + var missing = localProviders.Where(name => !enabled.Contains(name, StringComparer.OrdinalIgnoreCase)).ToArray(); + if (missing.Length == 0) + { + return false; + } + + // Local providers rank ahead of remote ones, and the enabled list doubles as the + // priority order when no explicit order was saved. + typeOptions.SimilarItemProviders = [.. missing, .. enabled]; + if (typeOptions.SimilarItemProviderOrder is { Length: > 0 } order) + { + typeOptions.SimilarItemProviderOrder = [.. missing, .. order]; + } + + _logger.LogInformation("Enabled local similarity providers {Providers} for '{ItemType}' in library '{LibraryName}'.", missing, typeOptions.Type, libraryName); + return true; + } + + private Dictionary<string, string[]> GetLocalProvidersByItemType() + { + var result = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase); + + foreach (var summary in _providerManager.GetAllMetadataPlugins()) + { + var names = summary.Plugins + .Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider) + .Select(p => p.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (names.Length > 0) + { + result[summary.ItemType] = names; + } + } + + return result; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs b/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs new file mode 100644 index 0000000000..43db38c92f --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Re-points every video that is linked as an alternate version at the primary it belongs to. +/// </summary> +[JellyfinMigration("2026-09-08T12:00:00", nameof(RepairAlternateVersionLinks))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RepairAlternateVersionLinks : IAsyncMigrationRoutine +{ + private const int BatchSize = 1000; + + private readonly IStartupLogger<RepairAlternateVersionLinks> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RepairAlternateVersionLinks"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbProvider">The database context factory.</param> + public RepairAlternateVersionLinks( + IStartupLogger<RepairAlternateVersionLinks> logger, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var links = await dbContext.LinkedChildren + .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion + || lc.ChildType == LinkedChildType.LinkedAlternateVersion) + .Select(lc => new { lc.ParentId, lc.ChildId, lc.ChildType }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (links.Count == 0) + { + _logger.LogInformation("No alternate version links found, nothing to repair."); + return; + } + + // A version belongs to one primary; a file-based link outranks a user-merged one, as it + // does everywhere else these two link types meet. + var primaryByChild = links + .GroupBy(l => l.ChildId) + .ToDictionary( + g => g.Key, + g => g.OrderBy(l => l.ChildType == LinkedChildType.LocalAlternateVersion ? 0 : 1) + .First() + .ParentId); + + // A version that is its own primary would be hidden from every list by the repair below. + foreach (var selfLink in primaryByChild.Where(kvp => kvp.Value.Equals(kvp.Key)).ToList()) + { + _logger.LogWarning("Skipping alternate version {ChildId}, which is linked to itself.", selfLink.Key); + primaryByChild.Remove(selfLink.Key); + } + + ResolvePrimaries(primaryByChild); + + var repaired = 0; + var promoted = 0; + + // The primaries are loaded along with their versions: a primary that carries a + // PrimaryVersionId of its own hides the whole group it heads. + var itemIds = primaryByChild.Keys.Concat(primaryByChild.Values).Distinct().ToList(); + for (var offset = 0; offset < itemIds.Count; offset += BatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batch = itemIds.GetRange(offset, Math.Min(BatchSize, itemIds.Count - offset)); + var items = await dbContext.BaseItems + .Where(e => batch.Contains(e.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var item in items) + { + if (primaryByChild.TryGetValue(item.Id, out var primaryId)) + { + // Mirrors Video.CreatePresentationUniqueKey for a video that has a primary. + var expectedKey = primaryId.ToString("N", CultureInfo.InvariantCulture); + if (item.PrimaryVersionId.HasValue + && primaryId.Equals(item.PrimaryVersionId.Value) + && string.Equals(item.PresentationUniqueKey, expectedKey, StringComparison.Ordinal)) + { + continue; + } + + item.PrimaryVersionId = primaryId; + item.PresentationUniqueKey = expectedKey; + repaired++; + } + else if (item.PrimaryVersionId.HasValue) + { + if (item.OwnerId.HasValue) + { + // An owned item is hidden by its owner rather than by its primary, so + // clearing the primary here would not bring the group back. + _logger.LogWarning( + "Alternate versions are linked to {ItemId}, which is owned by {OwnerId}; the group stays hidden until the owner is repaired.", + item.Id, + item.OwnerId.Value); + continue; + } + + // Nothing links this one as a version, so the leftover primary is stale and + // would hide it, and with it every version linked to it. + _logger.LogWarning( + "Clearing the stale primary {PrimaryVersionId} of {ItemId}, which other versions are linked to.", + item.PrimaryVersionId.Value, + item.Id); + + item.PrimaryVersionId = null; + item.PresentationUniqueKey = item.Id.ToString("N", CultureInfo.InvariantCulture); + promoted++; + } + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "Repaired {Repaired} of {Total} alternate version links, and promoted {Promoted} primaries that were versions themselves.", + repaired, + primaryByChild.Count, + promoted); + } + } + + private void ResolvePrimaries(Dictionary<Guid, Guid> primaryByChild) + { + var resolvedPrimaries = new Dictionary<Guid, Guid>(primaryByChild.Count); + + foreach (var start in primaryByChild.Keys.ToList()) + { + if (resolvedPrimaries.ContainsKey(start)) + { + continue; + } + + var chain = new List<Guid>(); + var walked = new HashSet<Guid>(); + var current = start; + Guid primary; + + while (true) + { + if (resolvedPrimaries.TryGetValue(current, out var resolved)) + { + primary = resolved; + break; + } + + if (!primaryByChild.TryGetValue(current, out var next)) + { + // Nothing is linking this one as a version of something else, so it heads the group. + primary = current; + break; + } + + if (!walked.Add(current)) + { + var loop = chain.Skip(chain.IndexOf(current)).ToList(); + + // Which member heads the group is arbitrary; the lowest id keeps the repair + // stable if the migration is ever re-run over the same data. + primary = loop.Min(); + _logger.LogWarning( + "Alternate version links form a loop ({Loop}); keeping {PrimaryId} as the primary of the group.", + string.Join(" -> ", loop), + primary); + + primaryByChild.Remove(primary); + break; + } + + chain.Add(current); + current = next; + } + + foreach (var version in chain) + { + resolvedPrimaries[version] = primary; + } + } + + foreach (var (version, primary) in resolvedPrimaries) + { + if (primary.Equals(version)) + { + // The member of a loop that was kept as the primary of its group. + continue; + } + + primaryByChild[version] = primary; + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs index ed92c34aa3..a456acf47d 100644 --- a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs @@ -11,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// Migrate rating levels. /// </summary> #pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigration("2026-09-10T12:00:00", nameof(MigrateRatingLevels))] [JellyfinMigrationBackup(JellyfinDb = true)] #pragma warning restore CS0618 // Type or member is obsolete internal class MigrateRatingLevels : IDatabaseMigrationRoutine diff --git a/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs new file mode 100644 index 0000000000..f61e42337a --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs @@ -0,0 +1,44 @@ +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Drops keys that no current property reads or writes from the serialized <c>BaseItems.Data</c> blob. +/// </summary> +[JellyfinMigration("2026-09-11T12:00:00", nameof(StripEmbeddedLinkedChildren))] +internal class StripEmbeddedLinkedChildren : IDatabaseMigrationRoutine +{ + private readonly ILogger<StripEmbeddedLinkedChildren> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + public StripEmbeddedLinkedChildren( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = loggerFactory.CreateLogger<StripEmbeddedLinkedChildren>(); + _dbProvider = dbProvider; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + // json_valid guards the rare malformed blob: json_remove would abort the statement on it, + // and one bad row must not cost every other row the fix. + var updated = context.Database.ExecuteSqlRaw( + """ + UPDATE "BaseItems" + SET "Data" = json_remove("Data", '$.LinkedChildren', '$.ExtraIds', '$.SupportsExternalTransfer') + WHERE "Data" IS NOT NULL + AND json_valid("Data") = 1 + AND ("Data" LIKE '%"LinkedChildren"%' + OR "Data" LIKE '%"ExtraIds"%' + OR "Data" LIKE '%"SupportsExternalTransfer"%') + """); + + _logger.LogInformation("Dropped dead keys from the serialized data of {Count} items", updated); + } +} diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs index 971b47608f..71706811b8 100644 --- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs +++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs @@ -4,8 +4,6 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Server.ServerSetupApp; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Stages; @@ -22,66 +20,45 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!; } - private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger) + public async Task Perform(IServiceProvider serviceProvider, IStartupLogger logger, CancellationToken cancellationToken) { - var childServiceCollection = new ServiceCollection() - .AddSingleton(serviceProvider) - .AddSingleton(logger) - .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>)) - .AddSingleton<StartupLogTopic>(logger.Topic!); + if (!IsMigrationRoutine(MigrationType)) + { + throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type"); + } - foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>()) + // The routine runs against a scope of the applications own container. Copying the application service + // descriptors into a child container instead would make that child container the owner of every singleton it + // forwards, so disposing it after the migration would also dispose the applications own instance of services + // like the ProviderManager and leave the server broken until the next restart. + var scope = serviceProvider.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) { - if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition) + // Nests everything the routine logs through an injected IStartupLogger under the migrations own topic. + using (StartupLogger.BeginAmbientTopic(logger.Topic)) { - childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!); - continue; + await RunAsync(ActivatorUtilities.CreateInstance(scope.ServiceProvider, MigrationType), cancellationToken).ConfigureAwait(false); } - - childServiceCollection.Add(service); } - - return childServiceCollection; } - public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken) - { + // The obsolete IMigrationRoutine is still implemented by every routine that predates the async interface, so + // the members that have to touch it are grouped here behind a single suppression. #pragma warning disable CS0618 // Type or member is obsolete - if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType)) - { - if (serviceProvider is null) - { - ((IMigrationRoutine)Activator.CreateInstance(MigrationType)!).Perform(); - } - else - { - using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider(); - ((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).Perform(); -#pragma warning restore CS0618 // Type or member is obsolete - } - } - else if (typeof(IAsyncMigrationRoutine).IsAssignableFrom(MigrationType)) - { - if (serviceProvider is null) - { - await ((IAsyncMigrationRoutine)Activator.CreateInstance(MigrationType)!).PerformAsync(cancellationToken).ConfigureAwait(false); - } - else - { - using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider(); - await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false); - } - } - else - { - throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type"); - } + private static bool IsMigrationRoutine(Type migrationType) + { + return typeof(IMigrationRoutine).IsAssignableFrom(migrationType) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(migrationType); } - private class NestedStartupLogger<TCategory> : StartupLogger<TCategory> + private static async Task RunAsync(object routine, CancellationToken cancellationToken) { - public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic) + if (routine is IMigrationRoutine migrationRoutine) { + migrationRoutine.Perform(); + return; } + + await ((IAsyncMigrationRoutine)routine).PerformAsync(cancellationToken).ConfigureAwait(false); } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 12f92efb35..715fc3d3b9 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -61,6 +61,7 @@ namespace Jellyfin.Server private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; private static IStartupLogger<JellyfinMigrationService>? _migrationLogger; + private static bool _optimizeDatabaseAfterMigration; private static string? _restoreFromBackup; /// <summary> @@ -180,9 +181,7 @@ namespace Jellyfin.Server }) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) .UseSerilog() - .ConfigureServices(e => e - .RegisterStartupLogger() - .AddSingleton<IServiceCollection>(e)) + .ConfigureServices(e => e.RegisterStartupLogger()) .Build(); /* @@ -209,14 +208,15 @@ namespace Jellyfin.Server await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); // "Preparing migrations" carries through the DB read; per-migration progress is reported // as "Running migration X of Y" from inside the step once the pending set is known. - await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); SetupServer.ReportActivity(StartupActivity.InitializingServices); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); _appHost = appHost; - await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false); await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false); + await OptimizeDatabaseAfterMigrationAsync(appHost.ServiceProvider).ConfigureAwait(false); try { configurationCompleted = true; @@ -271,12 +271,10 @@ namespace Jellyfin.Server // Don't throw additional exception if startup failed. if (appHost.ServiceProvider is not null) { - _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); + _logger.LogInformation("Preparing the database for shutdown..."); var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); - using var shutdownSource = new CancellationTokenSource(); - shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds); - await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false); + await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false); } _appHost = null; @@ -307,14 +305,13 @@ namespace Jellyfin.Server .AddSingleton<ServerApplicationPaths>(appPaths) .RegisterStartupLogger(); - migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider); var startupService = migrationStartupServiceProvider.BuildServiceProvider(); PrepareDatabaseProvider(startupService); var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService); await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false); - await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); } /// <summary> @@ -329,7 +326,32 @@ namespace Jellyfin.Server public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage) { var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!); - await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false); + _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false); + } + + private static async Task OptimizeDatabaseAfterMigrationAsync(IServiceProvider serviceProvider) + { + if (!_optimizeDatabaseAfterMigration) + { + return; + } + + // Reset first: a restart runs no migrations and must not optimize again. + _optimizeDatabaseAfterMigration = false; + SetupServer.ReportActivity(StartupActivity.OptimizingDatabase); + _logger.LogInformation("Migrations have been applied, optimizing the database... This might take a while"); + + try + { + // Deliberately untimed: incomplete statistics are worse than a slow start. + var databaseProvider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); + await databaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + // A missed optimization only costs performance, so never fail startup over this. + _logger.LogError(ex, "Error while optimizing the database after migration"); + } } /// <summary> diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs index 888cc617d4..abfc5bc8ba 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -27,6 +27,9 @@ public static class StartupActivity /// <summary>Bringing up core services and plugins.</summary> public const string InitializingServices = "Initializing services"; + /// <summary>Refreshing the database statistics after migrations have run.</summary> + public const string OptimizingDatabase = "Optimizing database"; + /// <summary>Running the final startup tasks.</summary> public const string FinishingStartup = "Finishing startup"; diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs index 0121854ce3..b72b0c0eab 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -8,6 +9,8 @@ namespace Jellyfin.Server.ServerSetupApp; /// <inheritdoc/> public class StartupLogger : IStartupLogger { + private static readonly AsyncLocal<StartupLogTopic?> _ambientTopic = new(); + private readonly StartupLogTopic? _topic; /// <summary> @@ -17,6 +20,7 @@ public class StartupLogger : IStartupLogger public StartupLogger(ILogger logger) { BaseLogger = logger; + _topic = _ambientTopic.Value; } /// <summary> @@ -39,6 +43,18 @@ public class StartupLogger : IStartupLogger /// </summary> protected ILogger BaseLogger { get; set; } + /// <summary> + /// Makes <paramref name="topic"/> the topic that loggers created on this execution context attach to. + /// </summary> + /// <param name="topic">The topic to nest newly created loggers under.</param> + /// <returns>A scope that restores the previously ambient topic when disposed.</returns> + internal static IDisposable BeginAmbientTopic(StartupLogTopic? topic) + { + var scope = new AmbientTopicScope(_ambientTopic.Value); + _ambientTopic.Value = topic; + return scope; + } + /// <inheritdoc/> public IStartupLogger BeginGroup(FormattableString logEntry) { @@ -121,4 +137,19 @@ public class StartupLogger : IStartupLogger Topic.Children.Add(startupEntry); } } + + private sealed class AmbientTopicScope : IDisposable + { + private readonly StartupLogTopic? _previous; + + public AmbientTopicScope(StartupLogTopic? previous) + { + _previous = previous; + } + + public void Dispose() + { + _ambientTopic.Value = _previous; + } + } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 1802440dc4..560a419945 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -18,6 +18,7 @@ using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations.Extensions; +using Jellyfin.Server.Implementations.Users; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; @@ -155,6 +156,7 @@ namespace Jellyfin.Server services.AddHostedService<LibraryChangedNotifier>(); services.AddHostedService<UserDataChangeNotifier>(); services.AddHostedService<RecordingNotifier>(); + services.AddHostedService<DeviceAccessHost>(); } /// <summary> diff --git a/Jellyfin.sln b/Jellyfin.sln index b0d5a5eb47..b666e4ae16 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -99,6 +99,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implement EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -265,6 +267,10 @@ Global {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -297,6 +303,7 @@ Global {A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} {11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index de07c7f2cb..1480a50c3b 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Common</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs index 4723be1001..221dda7d5f 100644 --- a/MediaBrowser.Common/Plugins/LocalPlugin.cs +++ b/MediaBrowser.Common/Plugins/LocalPlugin.cs @@ -73,6 +73,14 @@ namespace MediaBrowser.Common.Plugins public bool IsEnabledAndSupported => _supported && Manifest.Status >= PluginStatus.Active; /// <summary> + /// Gets or sets a value indicating whether a restart is required for the plugin's state to take effect. + /// </summary> + /// <remarks> + /// Memory only. <see cref="Manifest"/> holds the state that is persisted to disk. + /// </remarks> + public bool RestartRequired { get; set; } + + /// <summary> /// Gets a value indicating whether the plugin has a manifest. /// </summary> public PluginManifest Manifest { get; } @@ -108,7 +116,7 @@ namespace MediaBrowser.Common.Plugins public PluginInfo GetPluginInfo() { var inst = Instance?.GetPluginInfo() ?? new PluginInfo(Manifest.Name, Version, Manifest.Description, Manifest.Id, true); - inst.Status = Manifest.Status; + inst.Status = RestartRequired ? PluginStatus.Restart : Manifest.Status; inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath) || !string.IsNullOrEmpty(Manifest.ImageResourceName); return inst; } diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs index 14dc64dabd..36f0d2195c 100644 --- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using Jellyfin.Extensions; namespace MediaBrowser.Controller.ClientEvent { @@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent /// <inheritdoc /> public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) { - var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; + var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client"; + var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version"; + var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); + if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath)) + { + throw new ArgumentException("Path resolved to filename not in log directory"); + } + var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); await using (fileStream.ConfigureAwait(false)) { diff --git a/MediaBrowser.Controller/Drawing/ImageHelper.cs b/MediaBrowser.Controller/Drawing/ImageHelper.cs index 9ef92bc981..6f26b7d912 100644 --- a/MediaBrowser.Controller/Drawing/ImageHelper.cs +++ b/MediaBrowser.Controller/Drawing/ImageHelper.cs @@ -11,7 +11,9 @@ namespace MediaBrowser.Controller.Drawing // Determine the output size based on incoming parameters var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0); newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight); - return newSize; + + // Never encode larger than the source. + return DrawingUtils.ScaleDownToFit(newSize, originalImageSize); } } } diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index 052626355f..4259b7b65c 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -47,6 +47,20 @@ namespace MediaBrowser.Controller.Dto } /// <summary> + /// Gets options that populate nothing beyond the item's own stored columns. + /// </summary> + /// <remarks> + /// Each enabled field group is a collection the item query left-joins, so the rows returned are + /// the product of the item's provider, image and user data counts. Never use this for items that + /// get saved back: saving rewrites the owned rows from what the instance holds. + /// </remarks> + public static DtoOptions StoredColumnsOnly => new(false) + { + EnableImages = false, + EnableUserData = false + }; + + /// <summary> /// Gets or sets the fields to populate on the DTO. /// </summary> public IReadOnlyList<ItemFields> Fields { get; set; } diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index a02802f41e..ef24f632ed 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -54,6 +54,24 @@ namespace MediaBrowser.Controller.Entities public string[] PhysicalLocationsList { get; set; } + // Children caches the resolved items, _childrenIds the ids they were loaded from. Clearing + // only the former sends the next read back through LoadChildren, which replays the stale + // id list, so a caller invalidating this folder has to drop both. + [JsonIgnore] + public override IEnumerable<BaseItem> Children + { + get => base.Children; + set + { + if (value is null) + { + ClearCache(); + } + + base.Children = value; + } + } + public override bool CanDelete() { return false; diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index d016d8f62b..281a98dad5 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -165,6 +165,18 @@ namespace MediaBrowser.Controller.Entities.Audio public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { + try + { + await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + ReleaseCachedChildren(); + } + } + + private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) + { var items = GetRecursiveChildren(); var totalItems = items.Count; diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index c25694aba5..1e2d94d2a4 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -173,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName); } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 65669e6804..23b3341dbc 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -80,10 +80,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.MusicGenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 49a4ed4bf6..70e7da8932 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -47,6 +48,10 @@ namespace MediaBrowser.Controller.Entities public const string ThemeSongFileName = "theme"; + // Well below the 255 byte limit of the common Linux filesystems and the 255 character limit + // of Windows, so the files inside the folder still fit within MAX_PATH. + private const int MaxItemByNameFolderNameBytes = 128; + /// <summary> /// The supported image extensions. /// </summary> @@ -87,7 +92,7 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; - private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private protected static readonly char[] VersionDelimiters = ['-', '_', '.']; private string _sortName; @@ -540,8 +545,8 @@ namespace MediaBrowser.Controller.Entities { if (!string.IsNullOrEmpty(ForcedSortName)) { - // Need the ToLower because that's what CreateSortName does - _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant(); + // Run the forced sort name through the same cleaning as auto-generated sort names. + _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration); } else { @@ -770,6 +775,17 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol; + /// <summary> + /// Gets a value indicating whether this item searches the folder it lives in for its own extras. + /// </summary> + [JsonIgnore] + protected virtual bool SearchesContainingFolderForExtras => + IsFileProtocol + && SupportsOwnedItems + && !IsInMixedFolder + && this is not (ICollectionFolder or UserRootFolder or AggregateFolder) + && GetType() != typeof(Folder); + [JsonIgnore] public virtual bool SupportsPeople => false; @@ -926,19 +942,68 @@ namespace MediaBrowser.Controller.Entities /// <returns>System.String.</returns> protected virtual string CreateSortName() { - if (Name is null) + return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration); + } + + /// <summary> + /// Turns an item-by-name entity's name into a folder name every supported filesystem accepts. + /// </summary> + /// <param name="name">The entity's name.</param> + /// <returns>The folder name.</returns> + public static string GetItemByNameFolderName(string name) + { + // Trim the period at the end because windows will have a hard time with that + var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.'); + + // Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be + // turned into a folder at all - and an entity with no folder can never be created, which + // leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan. + // Only broken provider data gets this long, but it still has to resolve to something, so + // keep a readable prefix and let a hash of the whole name tell two of them apart. + if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes) + { + return validName; + } + + var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var budget = MaxItemByNameFolderNameBytes - suffix.Length; + var length = Math.Min(validName.Length, budget); + while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget) + { + length--; + } + + // Never cut a surrogate pair in half, the lone half is not a valid file name character. + if (length > 0 && char.IsHighSurrogate(validName[length - 1])) + { + length--; + } + + return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix); + } + + /// <summary> + /// Cleans a raw name into its sortable form by applying the configured sort rules. + /// </summary> + /// <param name="name">The raw name to clean.</param> + /// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param> + /// <param name="configuration">The server configuration providing the sort rules.</param> + /// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns> + public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration) + { + if (name is null) { return null; // some items may not have name filled in properly } - if (!EnableAlphaNumericSorting) + if (!enableAlphaNumericSorting) { - return Name.TrimStart(); + return name.TrimStart(); } - var sortable = Name.Trim().ToLowerInvariant(); + var sortable = name.Trim().ToLowerInvariant(); - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) + foreach (var search in configuration.SortRemoveWords) { // Remove from beginning if a space follows if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) @@ -956,12 +1021,12 @@ namespace MediaBrowser.Controller.Entities } } - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + foreach (var removeChar in configuration.SortRemoveCharacters) { sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); } - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + foreach (var replaceChar in configuration.SortReplaceCharacters) { sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); } @@ -1346,7 +1411,8 @@ namespace MediaBrowser.Controller.Entities /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is /// kept in the label, falling back to a space only when no structural delimiter is shared. The /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming - /// VideoFlagDelimiters). + /// VideoFlagDelimiters), except that a dot between digits is a decimal point rather than a + /// delimiter, so numeric version labels stay whole. /// </summary> /// <param name="fileNames">The version file names without extension; must contain at least one entry.</param> /// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns> @@ -1380,9 +1446,12 @@ namespace MediaBrowser.Controller.Entities if (!prefixIsWholeName) { - // Retreat to the last structural delimiter ('-', '_', '.'). + // Retreat to the last structural delimiter ('-', '_', '.'), skipping dots that are + // decimal points within a number rather than delimiters (see IsDecimalPoint). var cut = prefix.Length; - while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0) + while (cut > 0 + && (Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0 + || IsDecimalPoint(prefix, cut - 1, fileNames))) { cut--; } @@ -1402,6 +1471,31 @@ namespace MediaBrowser.Controller.Entities return prefix; } + private static bool IsDecimalPoint(string prefix, int index, IReadOnlyList<string> fileNames) + { + if (index == 0 || prefix[index] != '.' || !char.IsDigit(prefix[index - 1])) + { + return false; + } + + if (index + 1 < prefix.Length) + { + return char.IsDigit(prefix[index + 1]); + } + + // The dot ends the prefix, so the character after it is the first one that differs between + // the versions: only a decimal point when every version continues the number. + for (var i = 0; i < fileNames.Count; i++) + { + if (fileNames[i].Length <= index + 1 || !char.IsDigit(fileNames[i][index + 1])) + { + return false; + } + } + + return true; + } + public Task RefreshMetadata(CancellationToken cancellationToken) { return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); @@ -1515,7 +1609,14 @@ namespace MediaBrowser.Controller.Entities /// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns> protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder)) + if (!SearchesContainingFolderForExtras) + { + return false; + } + + if (GetParent() is Folder container + && container.SearchesContainingFolderForExtras + && string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1530,33 +1631,59 @@ namespace MediaBrowser.Controller.Entities private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { + // An extra is owned by the version it is named after, so all of them are maintained together. + var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery() + { + OwnerIds = item.GetOwnedVersionIds() + }).Where(e => e.ExtraType.HasValue).ToList(); + + var currentExtraIds = currentExtras.Select(e => e.Id).ToArray(); + + // Snapshot the persisted names before resolving, as FindExtras corrects the name on the + // items it hands back and may well hand back these very instances. + var currentExtraNames = new Dictionary<Guid, string>(); + foreach (var extra in currentExtras) + { + currentExtraNames[extra.Id] = extra.Name; + } + var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); var newExtraIds = Array.ConvertAll(extras, x => x.Id); - var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery() - { - OwnerIds = [item.Id] - }).Select(e => e.Id).ToArray(); + var renamedExtraIds = extras + .Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal)) + .Select(e => e.Id) + .ToHashSet(); var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); - if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) + if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { + // The owner's dates may only have become known after its extras were created, so keep + // them in sync even when there is nothing to refresh. + foreach (var extra in currentExtras) + { + if (extra.ExtraType is not null && InheritDatesFromOwner(item, extra)) + { + await extra.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + return false; } - var ownerId = item.Id; - var tasks = extras.Select(i => { + var ownerId = item.GetOwnerIdForExtra(i); var subOptions = new MetadataRefreshOptions(options); - if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) + if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id)) { subOptions.ForceSave = true; } i.OwnerId = ownerId; i.ParentId = Guid.Empty; + return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); }); @@ -2639,6 +2766,32 @@ namespace MediaBrowser.Controller.Entities } } + /// <summary> + /// Applies the owner's premiere date and production year to an owned item, returning whether anything changed. + /// </summary> + /// <param name="owner">The owner.</param> + /// <param name="ownedItem">The owned item.</param> + /// <returns><c>true</c> if the owned item was changed, else <c>false</c>.</returns> + internal static bool InheritDatesFromOwner(BaseItem owner, BaseItem ownedItem) + { + // Extras have no release date of their own, so the owner's is authoritative. + var changed = false; + + if (owner.ProductionYear is not null && ownedItem.ProductionYear != owner.ProductionYear) + { + ownedItem.ProductionYear = owner.ProductionYear; + changed = true; + } + + if (owner.PremiereDate is not null && ownedItem.PremiereDate != owner.PremiereDate) + { + ownedItem.PremiereDate = owner.PremiereDate; + changed = true; + } + + return changed; + } + protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken) { var newOptions = new MetadataRefreshOptions(options) @@ -2698,6 +2851,11 @@ namespace MediaBrowser.Controller.Entities ownedItem.CustomRating = item.CustomRating; newOptions.ForceSave = true; } + + if (InheritDatesFromOwner(item, ownedItem)) + { + newOptions.ForceSave = true; + } } await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false); @@ -2864,6 +3022,25 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Gets the ids of this item and the versions of it whose extras it maintains. + /// </summary> + /// <returns>An array containing the version ids.</returns> + protected virtual Guid[] GetOwnedVersionIds() + { + return [Id]; + } + + /// <summary> + /// Gets the id of the version an extra belongs to. + /// </summary> + /// <param name="extra">The extra.</param> + /// <returns>The id of the owning version.</returns> + protected virtual Guid GetOwnerIdForExtra(BaseItem extra) + { + return Id; + } + + /// <summary> /// Get all extras associated with this item, sorted by <see cref="SortName"/>. /// </summary> /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs index 5187669373..8559681bdc 100644 --- a/MediaBrowser.Controller/Entities/Book.cs +++ b/MediaBrowser.Controller/Entities/Book.cs @@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities [Common.RequiresSourceSerialisation] public class Book : BaseItem, IHasLookupInfo<BookInfo>, IHasSeries { - public Book() - { - this.RunTimeTicks = TimeSpan.TicksPerSecond; - } - [JsonIgnore] public override MediaType MediaType => MediaType.Book; diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..626bc0d5a1 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities public class Folder : BaseItem { private IEnumerable<BaseItem> _children; - - public Folder() - { - LinkedChildren = Array.Empty<LinkedChild>(); - } + private LinkedChild[] _linkedChildren = []; public static IUserViewManager UserViewManager { get; set; } @@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the linked children. /// </summary> [JsonIgnore] - public LinkedChild[] LinkedChildren { get; set; } + public LinkedChild[] LinkedChildren + { + get => _linkedChildren; + set + { + _linkedChildren = value; + + // Assigning the collection means the caller knows the complete set of links. + LinkedChildrenLoaded = true; + } + } + + /// <summary> + /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links. + /// </summary> + /// <remarks> + /// An unloaded instance carries an empty array that means "unknown", not "no children" — + /// persisting it would delete every link the item has. + /// </remarks> + [JsonIgnore] + public bool LinkedChildrenLoaded { get; private set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } @@ -270,6 +286,27 @@ namespace MediaBrowser.Controller.Entities return GetCachedChildren(); } + /// <summary> + /// Drops the children this folder has materialised, and the ones held by every folder below + /// it, without loading anything that is not already in memory. + /// </summary> + public void ReleaseCachedChildren() + { + // Cleared before descending, so a folder already on the way down is not walked twice. + var children = _children; + _children = null; + + if (children is null) + { + return; + } + + foreach (var child in children) + { + (child as Folder)?.ReleaseCachedChildren(); + } + } + public override double? GetRefreshProgress() { return ProviderManager.GetRefreshProgress(Id); @@ -300,7 +337,7 @@ namespace MediaBrowser.Controller.Entities var dictionary = new Dictionary<Guid, BaseItem>(); Children = null; // invalidate cached children. - var childrenList = Children.ToList(); + var childrenList = GetChildrenForValidation(); foreach (var child in childrenList) { @@ -354,6 +391,9 @@ namespace MediaBrowser.Controller.Entities { ProviderManager.OnRefreshComplete(this); } + + // The subtree is done with, so stop holding it. + ReleaseCachedChildren(); } } @@ -389,19 +429,23 @@ namespace MediaBrowser.Controller.Entities if (IsFileProtocol) { - IEnumerable<BaseItem> nonCachedChildren = []; + IEnumerable<BaseItem> nonCachedChildren; try { - nonCachedChildren = GetNonCachedChildren(directoryService); + // Finish enumeration before mutating the library. An I/O failure, including + // one partway through a lazy enumeration, must not look like removed files. + nonCachedChildren = GetNonCachedChildren(directoryService).ToArray(); } catch (IOException ex) { Logger.LogError(ex, "Error retrieving children from file system"); + return; } catch (SecurityException ex) { Logger.LogError(ex, "Error retrieving children from file system"); + return; } catch (Exception ex) { @@ -535,7 +579,7 @@ namespace MediaBrowser.Controller.Entities && primaryVideo.OwnerId.IsEmpty() && (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p))) { - var newPrimary = newItems + var newPrimary = validChildren .OfType<Video>() .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) .Any(p => (primaryVideo.LocalAlternateVersions ?? []) @@ -577,6 +621,8 @@ namespace MediaBrowser.Controller.Entities newPrimary.Name, newPrimary.Id); + await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false); + // Reroute collection/playlist references from old primary to new primary await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false); @@ -605,9 +651,12 @@ namespace MediaBrowser.Controller.Entities LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false); } - // Demote old primaries that are now alternate versions of newly created primaries. + // Demote old primaries that are now alternate versions of another primary. // This handles the case where a new file is added that becomes the new primary - // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion). + // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion), and the case + // where the file that takes over was already in the library and merely traded + // places with this one — so the new primary is looked up among all valid children + // rather than only the newly created ones. // Items in replacedPrimaries are excluded (already in actuallyRemoved). var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>(); foreach (var item in itemsRemoved.Except(actuallyRemoved)) @@ -617,7 +666,7 @@ namespace MediaBrowser.Controller.Entities && !string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path)) { - var newPrimary = newItems + var newPrimary = validChildren .OfType<Video>() .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) .Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase))); @@ -637,10 +686,13 @@ namespace MediaBrowser.Controller.Entities newPrimary.Name, newPrimary.Id); + await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false); + // First: update old primary's alternate items to point to new primary. // Order matters — update alternates FIRST so they don't get orphan-deleted // when old primary's arrays are cleared. - var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary) + var oldLocalAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet(); + var oldAlternateIds = oldLocalAlternateIds .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id)) .Distinct() .ToList(); @@ -650,7 +702,10 @@ namespace MediaBrowser.Controller.Entities if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id)) { altVideo.SetPrimaryVersionId(newPrimary.Id); - altVideo.OwnerId = newPrimary.Id; + + // Only a version stored next to the new primary is owned by it; one that + // was merged in by hand keeps its own row and must stay unowned. + altVideo.OwnerId = oldLocalAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty; await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); } } @@ -756,6 +811,23 @@ namespace MediaBrowser.Controller.Entities } } + private async Task PromoteToPrimaryVersionAsync(Video newPrimary, CancellationToken cancellationToken) + { + if (!newPrimary.PrimaryVersionId.HasValue && newPrimary.OwnerId.IsEmpty()) + { + return; + } + + Logger.LogInformation( + "Promoting {Name} ({Id}) to the primary version of its group", + newPrimary.Name, + newPrimary.Id); + + newPrimary.SetPrimaryVersionId(null); + newPrimary.OwnerId = Guid.Empty; + await newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + private async Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken) { await RunTasks( @@ -791,7 +863,14 @@ namespace MediaBrowser.Controller.Entities if (recursive && child is Folder folder) { folder.Children = null; // invalidate cached children. - await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + try + { + await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + folder.ReleaseCachedChildren(); + } } } } @@ -860,6 +939,17 @@ namespace MediaBrowser.Controller.Entities }); } + private IReadOnlyList<BaseItem> GetChildrenForValidation() + { + return ItemRepository.GetItemList(new InternalItemsQuery + { + Parent = this, + GroupByPresentationUniqueKey = false, + IncludeAlternateVersions = true, + DtoOptions = new DtoOptions(true) + }); + } + public virtual int GetChildCount(User user) { if (LinkedChildren.Length > 0) @@ -1085,15 +1175,7 @@ namespace MediaBrowser.Controller.Entities items = ApplyNameFilter(items, query); } - var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); - var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); - - if (query.EnableTotalRecordCount) - { - result.TotalRecordCount = filteredItems.Count; - } - - return result; + return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); } private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query) @@ -1605,7 +1687,17 @@ namespace MediaBrowser.Controller.Entities /// <returns>IEnumerable{BaseItem}.</returns> public List<BaseItem> GetLinkedChildren() { - var resolved = ResolveLinkedChildren(LinkedChildren); + return GetLinkedChildren(new DtoOptions()); + } + + /// <summary> + /// Gets the linked children, populating only what <paramref name="options"/> asks for. + /// </summary> + /// <param name="options">Fields to populate on the resolved children.</param> + /// <returns>The resolved children.</returns> + public List<BaseItem> GetLinkedChildren(DtoOptions options) + { + var resolved = ResolveLinkedChildren(LinkedChildren, options); var list = new List<BaseItem>(resolved.Count); foreach (var (_, item) in resolved) { @@ -1722,8 +1814,9 @@ namespace MediaBrowser.Controller.Entities /// path (legacy path-based resolution). /// </summary> /// <param name="linkedChildren">Linked children to resolve.</param> + /// <param name="options">Fields to populate on the resolved items; all fields when null.</param> /// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns> - private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren) + private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren, DtoOptions options = null) { var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count); if (linkedChildren.Count == 0) @@ -1745,7 +1838,8 @@ namespace MediaBrowser.Controller.Entities { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToBatch] + ItemIds = [.. idsToBatch], + DtoOptions = options ?? new DtoOptions() }); byId = new Dictionary<Guid, BaseItem>(batched.Count); foreach (var item in batched) diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index 6ec78a270e..ef8acaef92 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -83,10 +83,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.GenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs index f47d2162f7..0cdc8bce03 100644 --- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs +++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs @@ -1,12 +1,13 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Entities { + /// <summary> + /// Interface for items that have special features. + /// </summary> public interface IHasSpecialFeatures { /// <summary> diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs index dab15eb018..47df09d1ce 100644 --- a/MediaBrowser.Controller/Entities/IHasStartDate.cs +++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs @@ -1,11 +1,15 @@ -#pragma warning disable CS1591 - using System; namespace MediaBrowser.Controller.Entities { + /// <summary> + /// Interface for items that have a start date. + /// </summary> public interface IHasStartDate { + /// <summary> + /// Gets or sets the start date. + /// </summary> DateTime StartDate { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs index 4928bda7a2..756dbecb98 100644 --- a/MediaBrowser.Controller/Entities/IItemByName.cs +++ b/MediaBrowser.Controller/Entities/IItemByName.cs @@ -1,19 +1,28 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; namespace MediaBrowser.Controller.Entities { /// <summary> - /// Marker interface. + /// Marker interface for items that represent a name, like a genre or a studio. /// </summary> public interface IItemByName { + /// <summary> + /// Gets the items tagged with this name. + /// </summary> + /// <param name="query">The query.</param> + /// <returns>The tagged items.</returns> IReadOnlyList<BaseItem> GetTaggedItems(InternalItemsQuery query); } + /// <summary> + /// Interface for by-name items that can also be accessed as a regular library item. + /// </summary> public interface IHasDualAccess : IItemByName { + /// <summary> + /// Gets a value indicating whether the item is accessed by name. + /// </summary> bool IsAccessedByName { get; } } } diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs index cdda8ea399..0f8904df5c 100644 --- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs +++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Entities { + /// <summary> + /// Interface for items that can be placeholders. + /// </summary> public interface ISupportsPlaceHolders { /// <summary> diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 3b1f6a961f..7c88d5dd05 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -103,6 +103,7 @@ namespace MediaBrowser.Controller.Entities || SubtitleLanguages.Count > 0 || LinkedChildAncestorIds.Length > 0 || AncestorIds.Length > 0 + || DescendantOfId.HasValue || IsFavorite.HasValue || IsFavoriteOrLiked.HasValue || IsLiked.HasValue @@ -368,6 +369,13 @@ namespace MediaBrowser.Controller.Entities /// </summary> public Guid[] LinkedChildAncestorIds { get; set; } + /// <summary> + /// Gets or sets the id of a folder whose descendants the items must be part of. + /// Unlike <see cref="AncestorIds"/> this also follows the linked children of BoxSets and + /// Playlists, so it reaches the items below a linked folder (a Series' episodes, for example). + /// </summary> + public Guid? DescendantOfId { get; set; } + public Guid[] TopParentIds { get; set; } public CollectionType?[] PresetViews { get; set; } @@ -424,12 +432,18 @@ namespace MediaBrowser.Controller.Entities public string? HasNoSubtitleTrackWithLanguage { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to return only items nothing names any more. + /// </summary> public bool? IsDeadArtist { get; set; } public bool? IsDeadStudio { get; set; } public bool? IsDeadGenre { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to return only items nothing names any more. + /// </summary> public bool? IsDeadPerson { get; set; } /// <summary> @@ -474,6 +488,14 @@ namespace MediaBrowser.Controller.Entities /// </summary> public bool IncludeOwnedItems { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to include alternate versions, which carry a + /// <see cref="Video.PrimaryVersionId"/> and are normally hidden behind the version they + /// belong to. Unlike <see cref="IncludeOwnedItems"/> this keeps the versions a user merged + /// by hand without also returning the parts and extras owned by another item. + /// </summary> + public bool IncludeAlternateVersions { get; set; } + public bool? Is4K { get; set; } public int? MaxHeight { get; set; } @@ -496,6 +518,12 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList<string> SubtitleLanguages { get; set; } + /// <summary> + /// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>. + /// Filters that only exist to hide content can be skipped entirely when this is false. + /// </summary> + public bool UserHasContentRestrictions { get; private set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; @@ -519,6 +547,7 @@ namespace MediaBrowser.Controller.Entities .Select(tag => tag.RemoveDiacritics().ToLowerInvariant()) .ToArray(); + UserHasContentRestrictions = user.HasContentRestrictions(); User = user; } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index e12ba22343..8d2a959f4d 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities { PersonTypes = personTypes; ExcludePersonTypes = excludePersonTypes; + EnableTotalRecordCount = true; } + /// <summary> + /// Gets or sets a value indicating whether to count the matching people. Under an + /// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops + /// at the limit, the count has to check every person. + /// </summary> + public bool EnableTotalRecordCount { get; set; } + public int? StartIndex { get; set; } /// <summary> @@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities public User User { get; set; } public bool? IsFavorite { get; set; } + + /// <summary> + /// Gets or sets the item query whose access settings (library access, parental rating, tags) + /// people must satisfy through at least one of the items they are credited on. + /// </summary> + public InternalItemsQuery AccessFilter { get; set; } } } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 8216937cad..16d8bfe391 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -11,6 +11,7 @@ using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -89,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies return base.GetNonCachedChildren(directoryService); } - return Enumerable.Empty<BaseItem>(); + return []; } protected override IReadOnlyList<BaseItem> LoadChildren() @@ -168,14 +169,21 @@ namespace MediaBrowser.Controller.Entities.Movies return true; } - var userLibraryFolderIds = GetLibraryFolderIds(user); - var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + List<BaseItem> linkedItems = null; + var libraryFolderIds = LibraryFolderIds; + if (libraryFolderIds is null) + { + linkedItems = GetLinkedChildren(DtoOptions.StoredColumnsOnly); + libraryFolderIds = GetLibraryFolderIds(linkedItems); + } if (libraryFolderIds.Length == 0) { return true; } + var userLibraryFolderIds = GetLibraryFolderIds(user); + if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i))) { return false; @@ -184,7 +192,7 @@ namespace MediaBrowser.Controller.Entities.Movies // If user has parental controls, hide the BoxSet when all children are restricted if (user.MaxParentalRatingScore.HasValue) { - var linkedItems = GetLinkedChildren(); + linkedItems ??= GetLinkedChildren(DtoOptions.StoredColumnsOnly); if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { return false; @@ -241,10 +249,19 @@ namespace MediaBrowser.Controller.Entities.Movies public Guid[] GetLibraryFolderIds() { - var expandedFolders = new List<Guid>(); + return GetLibraryFolderIds(GetLinkedChildren(DtoOptions.StoredColumnsOnly)); + } + + private Guid[] GetLibraryFolderIds(IEnumerable<BaseItem> linkedChildren) + { + // Seeded with this box set so a cycle through a nested collection terminates. + var expandedFolders = new List<Guid> { Id }; + + // The user root children are the same for every item. + var rootChildren = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); - return FlattenItems(this, expandedFolders) - .SelectMany(LibraryManager.GetCollectionFolders) + return FlattenItems(linkedChildren, expandedFolders) + .SelectMany(i => LibraryManager.GetCollectionFolders(i, rootChildren)) .Select(i => i.Id) .Distinct() .ToArray(); @@ -264,13 +281,13 @@ namespace MediaBrowser.Controller.Entities.Movies { expandedFolders.Add(item.Id); - return FlattenItems(boxset.GetLinkedChildren(), expandedFolders); + return FlattenItems(boxset.GetLinkedChildren(DtoOptions.StoredColumnsOnly), expandedFolders); } - return Array.Empty<BaseItem>(); + return []; } - return new[] { item }; + return [item]; } } } diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index e8817a29cf..8c3ce2ff58 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -90,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/MusicVideo.cs b/MediaBrowser.Controller/Entities/MusicVideo.cs index 237ad5198c..effbf98820 100644 --- a/MediaBrowser.Controller/Entities/MusicVideo.cs +++ b/MediaBrowser.Controller/Entities/MusicVideo.cs @@ -40,7 +40,7 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/PeopleHelper.cs b/MediaBrowser.Controller/Entities/PeopleHelper.cs index 24b1843ce6..29f238d8ea 100644 --- a/MediaBrowser.Controller/Entities/PeopleHelper.cs +++ b/MediaBrowser.Controller/Entities/PeopleHelper.cs @@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities person.Type = PersonKind.Writer; } - // If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes - if (person.Type == PersonKind.GuestStar) - { - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor); + // Check for dupes based on the combination of Name, Type and Role. + var existing = people.FirstOrDefault(p => IsSameCredit(p, person) + && string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase)); - if (existing is not null) - { - existing.Type = PersonKind.GuestStar; - MergeExisting(existing, person); - return; - } - } - - if (person.Type == PersonKind.Actor) + if (existing is null) { - // If the actor already exists without a role and we have one, fill it in - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar)); - if (existing is null) + if (string.IsNullOrEmpty(person.Role)) { - // Wasn't there - add it - people.Add(person); + existing = people.FirstOrDefault(p => IsSameCredit(p, person)); } else { - // Was there, if no role and we have one - fill it in - if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role)) + // If the person already exists without a role and we have one, fill it in + existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role)); + if (existing is not null) { existing.Role = person.Role; } - - MergeExisting(existing, person); } } - else + + if (existing is null) { - var existing = people.FirstOrDefault(p => - string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase) - && p.Type == person.Type); + people.Add(person); + return; + } - // Check for dupes based on the combination of Name and Type - if (existing is null) - { - people.Add(person); - } - else - { - MergeExisting(existing, person); - } + // If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes + if (person.Type == PersonKind.GuestStar) + { + existing.Type = PersonKind.GuestStar; } + + MergeExisting(existing, person); } + private static bool IsSameCredit(PersonInfo existing, PersonInfo person) + { + if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Actor and GuestStar describe the same credit, a guest star is just a promoted actor. + if (IsCastKind(existing.Type) && IsCastKind(person.Type)) + { + return true; + } + + return existing.Type == person.Type; + } + + private static bool IsCastKind(PersonKind kind) + => kind is PersonKind.Actor or PersonKind.GuestStar; + private static void MergeExisting(PersonInfo existing, PersonInfo person) { existing.SortOrder = person.SortOrder ?? existing.SortOrder; diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 5cc4d322f7..bba5005eed 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities return false; } + /// <inheritdoc /> + /// <remarks> + /// People don't carry the tags of the media they appear in, so the allowed tags check + /// is skipped for them; otherwise no person would be visible to users with allowed tags configured. + /// </remarks> + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) + { + return base.IsVisible(user, true); + } + public override bool IsSaveLocalMetadataEnabled() { return true; @@ -87,10 +98,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validFilename = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validFilename = normalizeName ? GetItemByNameFolderName(name) : name; string subFolderPrefix = null; diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs index be19e1bdae..97aa22dc04 100644 --- a/MediaBrowser.Controller/Entities/SourceType.cs +++ b/MediaBrowser.Controller/Entities/SourceType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Entities { + /// <summary> + /// The source of an item. + /// </summary> public enum SourceType { + /// <summary> + /// The item comes from a library. + /// </summary> Library = 0, + + /// <summary> + /// The item comes from a channel. + /// </summary> Channel = 1, + + /// <summary> + /// The item comes from live TV. + /// </summary> LiveTV = 2 } } diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index 9103b09a95..a944b356c8 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -78,10 +78,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.StudioPath, validName); } diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 42e4f79942..40f917d50c 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities.TV public int? IndexNumberEnd { get; set; } [JsonIgnore] - protected override bool SupportsOwnedItems => IsStacked || MediaSourceCount > 1; + protected override bool SupportsOwnedItems => IsStacked || LocalAlternateVersions.Length > 0 || MediaSourceCount > 1; [JsonIgnore] public override bool SupportsInheritedParentImages => true; diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 952187c6e1..126f4361ba 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -82,9 +82,15 @@ namespace MediaBrowser.Controller.Entities.TV { var userdatakeys = GetUserDataKeys(); - if (userdatakeys.Count > 1) + // The first user data key is a stable cross-folder identity. + // When none exists, fall back to the (normalized) series name. + var groupingKey = userdatakeys.Count > 1 + ? userdatakeys[0] + : GetNameBasedGroupingKey(); + + if (!string.IsNullOrEmpty(groupingKey)) { - return AddLibrariesToPresentationUniqueKey(userdatakeys[0]); + return AddLibrariesToPresentationUniqueKey(groupingKey); } } @@ -101,6 +107,7 @@ namespace MediaBrowser.Controller.Entities.TV var folders = LibraryManager.GetCollectionFolders(this) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) + .Order(StringComparer.Ordinal) .ToArray(); if (folders.Length == 0) @@ -111,6 +118,14 @@ namespace MediaBrowser.Controller.Entities.TV return key + "-" + string.Join('-', folders); } + private string GetNameBasedGroupingKey() + { + // Prefix with the type so a series can never collide with a same-named item of another kind. + return string.IsNullOrEmpty(Name) + ? null + : "series-" + Name.ToLowerInvariant(); + } + private static string GetUniqueSeriesKey(BaseItem series) { return series.GetPresentationUniqueKey(); @@ -120,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV { var seriesKey = GetUniqueSeriesKey(this); - var result = LibraryManager.GetCount(new InternalItemsQuery(user) + var result = LibraryManager.GetItemIds(new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, IncludeItemTypes = new[] { BaseItemKind.Season }, IsVirtualItem = false, - Limit = 0, DtoOptions = new DtoOptions(false) { EnableImages = false } }); - return result; + return result.Count; } public override int GetRecursiveChildCount(User user) @@ -188,6 +202,25 @@ namespace MediaBrowser.Controller.Entities.TV return list; } + /// <inheritdoc /> + protected override Guid[] GetExtraOwnerIds() + { + if (!LibraryManager.GetLibraryOptions(this).EnableAutomaticSeriesGrouping) + { + return base.GetExtraOwnerIds(); + } + + // Setting PresentationUniqueKey on the query disables presentation-key grouping, so this + // returns every folder-item of the merged series rather than the collapsed survivor. + var ids = LibraryManager.GetItemIds(new InternalItemsQuery + { + PresentationUniqueKey = GetPresentationUniqueKey(), + IncludeItemTypes = [BaseItemKind.Series] + }); + + return ids.Count == 0 ? base.GetExtraOwnerIds() : ids.ToArray(); + } + public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { return GetSeasons(user, new DtoOptions(true)); @@ -300,6 +333,19 @@ namespace MediaBrowser.Controller.Entities.TV public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { Children = null; // invalidate cached children. + + try + { + await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + ReleaseCachedChildren(); + } + } + + private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) + { // Refresh bottom up, seasons and episodes first, then the series var items = GetRecursiveChildren(); @@ -507,7 +553,7 @@ namespace MediaBrowser.Controller.Entities.TV { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs index 939709215c..a2465eedf0 100644 --- a/MediaBrowser.Controller/Entities/Trailer.cs +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -49,7 +49,7 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata); - if (!ProductionYear.HasValue) + if (ProductionYear is null) { var info = LibraryManager.ParseName(Name); diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index c57ed2faf8..82256cd964 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -455,24 +455,34 @@ namespace MediaBrowser.Controller.Entities { var itemList = filtered.ToList(); var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList(); + var leaves = itemList.Where(i => i is not Folder).ToList(); + var isPlayedValue = query.IsPlayed.Value; - if (folderIds.Count > 0) - { - var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); - var isPlayedValue = query.IsPlayed.Value; + var counts = folderIds.Count > 0 + ? libraryManager.GetPlayedAndTotalCountBatch(folderIds, user) + : null; + + // A movie held as several files is watched once any of its versions is watched. + var resumeData = leaves.Count > 0 + ? userDataManager.GetResumeUserDataBatch(leaves, user) + : null; - return itemList.Where(i => + return itemList.Where(item => + { + if (item is Folder) { - if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) - { - return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; - } + var itemCount = counts?.GetValueOrDefault(item.Id) ?? default; + return (itemCount.Played >= itemCount.Total) == isPlayedValue; + } - return true; - }); - } + var played = userDataManager.GetUserData(user, item)?.Played ?? false; + if (!played && resumeData is not null && resumeData.TryGetValue(item.Id, out var versionData)) + { + played = versionData.UserData.Played; + } - return itemList; + return played == isPlayedValue; + }); } return filtered; @@ -490,6 +500,13 @@ namespace MediaBrowser.Controller.Entities } var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray(); + + // Adjacency is defined by the order the query asked for, so it has to run after sorting but before paging. + if (!query.AdjacentTo.IsNullOrEmpty()) + { + itemsArray = FilterForAdjacency(itemsArray, query.AdjacentTo.Value).ToArray(); + } + var totalCount = itemsArray.Length; if (query.Limit.HasValue && query.Limit.Value > 0) @@ -598,19 +615,7 @@ namespace MediaBrowser.Controller.Entities } } - if (query.IsPlayed.HasValue) - { - // Folder.IsPlayed() hits the DB per-item (N+1 queries). - // Folders are batch-filtered by the collection Filter() overload. - if (!item.IsFolder) - { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) - { - return false; - } - } - } + // IsPlayed is answered by the collection Filter() overload for folders and leaves alike. if (query.IsLocked.HasValue) { @@ -730,7 +735,7 @@ namespace MediaBrowser.Controller.Entities // Apply year filter if (query.Years.Length > 0) { - if (!(item.ProductionYear.HasValue && query.Years.Contains(item.ProductionYear.Value))) + if (item.ProductionYear is null || !query.Years.Contains(item.ProductionYear.Value)) { return false; } @@ -886,26 +891,32 @@ namespace MediaBrowser.Controller.Entities return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName); } - public static IEnumerable<BaseItem> FilterForAdjacency(List<BaseItem> list, Guid adjacentTo) + /// <summary> + /// Trims an ordered list down to the requested item and its immediate neighbours. + /// </summary> + /// <param name="list">The items in the order the query returned them.</param> + /// <param name="adjacentTo">The id of the item to return the neighbours of.</param> + /// <returns>The previous item, the requested item and the next item, in order.</returns> + public static IEnumerable<BaseItem> FilterForAdjacency(IReadOnlyList<BaseItem> list, Guid adjacentTo) { - var adjacentToItem = list.FirstOrDefault(i => i.Id.Equals(adjacentTo)); - - var index = list.IndexOf(adjacentToItem); - - var previousId = Guid.Empty; - var nextId = Guid.Empty; - - if (index > 0) + var index = -1; + for (var i = 0; i < list.Count; i++) { - previousId = list[index - 1].Id; + if (list[i].Id.Equals(adjacentTo)) + { + index = i; + break; + } } - if (index < list.Count - 1) + // The item isn't part of this result set, so it has no neighbours in it either. + if (index < 0) { - nextId = list[index + 1].Id; + return []; } - return list.Where(i => i.Id.Equals(previousId) || i.Id.Equals(nextId) || i.Id.Equals(adjacentTo)); + var start = Math.Max(index - 1, 0); + return list.Skip(start).Take(Math.Min(index + 2, list.Count) - start); } } } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 0606fe1870..e2f91aa04a 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -527,7 +527,13 @@ namespace MediaBrowser.Controller.Entities protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { - var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + var hasChanges = false; + + // The extras of a version group are maintained by its primary. + if (!PrimaryVersionId.HasValue) + { + hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + } // Clean up LocalAlternateVersions - remove paths that no longer exist if (LocalAlternateVersions.Length > 0) @@ -588,10 +594,20 @@ namespace MediaBrowser.Controller.Entities { altVideo.OwnerId = Id; altVideo.SetPrimaryVersionId(Id); + altVideo.IsInMixedFolder = IsInMixedFolder; LibraryManager.CreateItem(altVideo, GetParent()); } } + // A version is resolved on its own, so it does not learn whether the folder it sits in + // holds other items. It has to share that with the version it belongs to, before the + // refresh below acts on it. + if (LibraryManager.GetItemById(id) is Video resolvedVersion && resolvedVersion.IsInMixedFolder != IsInMixedFolder) + { + resolvedVersion.IsInMixedFolder = IsInMixedFolder; + await resolvedVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false); // Create LinkedChild entry for this local alternate version @@ -671,6 +687,7 @@ namespace MediaBrowser.Controller.Entities video.Id = id; video.OwnerId = Id; + video.IsInMixedFolder = IsInMixedFolder; LibraryManager.CreateItem(video, parentFolder); newOptions.ForceSave = true; } @@ -751,6 +768,80 @@ namespace MediaBrowser.Controller.Entities .ToArray(); } + /// <inheritdoc /> + protected override Guid[] GetOwnedVersionIds() + { + // Only the versions that live beside this one in the folder this scan covers. Linked + // versions are items of their own and maintain their extras themselves. + return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)]; + } + + /// <inheritdoc /> + protected override Guid GetOwnerIdForExtra(BaseItem extra) + { + if (string.IsNullOrEmpty(extra.Path)) + { + return Id; + } + + var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan()); + var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan()); + + var ownerId = Id; + var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName); + + foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this)) + { + var version = LibraryManager.GetItemById(versionId); + if (version is null) + { + continue; + } + + // "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the + // primary version, whose name it also starts with when the primary is plain "Movie.mkv" + var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName); + if (length > matchedLength) + { + matchedLength = length; + ownerId = versionId; + } + } + + return ownerId; + } + + /// <summary> + /// Gets how much of an extra's file name is the name of the given version file, or 0 when the + /// extra is not named after it. + /// </summary> + /// <param name="versionPath">The path of the version.</param> + /// <param name="extraDirectory">The directory the extra lives in.</param> + /// <param name="extraFileName">The file name of the extra, without extension.</param> + /// <returns>The length of the match.</returns> + private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan<char> extraDirectory, ReadOnlySpan<char> extraFileName) + { + if (string.IsNullOrEmpty(versionPath) + || !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan()); + if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + // The version name has to end where the extra's own name begins, so that a version + // named "Movie - 4K" does not claim the extras of "Movie - 4Kish" + var remainder = extraFileName[versionFileName.Length..]; + + return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0) + ? versionFileName.Length + : 0; + } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() { var primary = PrimaryVersionId.HasValue diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index 37820296cc..03fb2156d3 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -85,10 +85,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.YearPath, validName); } diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs index 44b7fadf5e..b2d2273cbe 100644 --- a/MediaBrowser.Controller/IO/FileSystemHelper.cs +++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs @@ -166,4 +166,40 @@ public static class FileSystemHelper return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget); } + + /// <summary> + /// Combines a caller supplied name with a parent directory, making sure the name cannot escape that directory. + /// </summary> + /// <param name="parentPath">The directory the name has to resolve inside of.</param> + /// <param name="name">The name of the child.</param> + /// <returns> + /// The full path of the child, or <c>null</c> if <paramref name="name"/> is not the name of a direct child + /// of <paramref name="parentPath"/>. + /// </returns> + public static string? GetChildPath(string parentPath, string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Contains('\0', StringComparison.Ordinal)) + { + return null; + } + + // Rejects directory separators, and on Windows also volume separators, as those make the name more than a single segment. + if (!string.Equals(Path.GetFileName(name), name, StringComparison.Ordinal)) + { + return null; + } + + var fullPath = Path.GetFullPath(Path.Combine(parentPath, name)); + var fullParentPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentPath)); + + // Catches the remaining relative names, "." and "..", which are valid single segments. + if (!string.Equals(Path.GetDirectoryName(fullPath), fullParentPath, StringComparison.Ordinal)) + { + return null; + } + + // Windows strips trailing dots and spaces, so a name like "..." resolves to the parent directory itself + // and a name like "Movies." to a different child. Reject anything normalization did not leave intact. + return string.Equals(Path.GetFileName(fullPath), name, StringComparison.Ordinal) ? fullPath : null; + } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 0b64da291c..82de3546f0 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -107,6 +107,13 @@ namespace MediaBrowser.Controller.Library Person? GetPerson(string name); /// <summary> + /// Gets a Person, creating and persisting it if no item exists for the name yet. + /// </summary> + /// <param name="name">The name of the person.</param> + /// <returns>The person.</returns> + Person GetOrCreatePerson(string name); + + /// <summary> /// Finds the by path. /// </summary> /// <param name="path">The path.</param> @@ -153,15 +160,6 @@ namespace MediaBrowser.Controller.Library Year GetYear(int value); /// <summary> - /// Validate and refresh the People sub-set of the IBN. - /// The items are stored in the db but not loaded into memory until actually requested by an operation. - /// </summary> - /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken); - - /// <summary> /// Reloads the root media folder. /// </summary> /// <param name="progress">The progress.</param> @@ -256,6 +254,14 @@ namespace MediaBrowser.Controller.Library IEnumerable<Video> GetLinkedAlternateVersions(Video video); /// <summary> + /// Gets, in a single query, the subset of the supplied items that own at least one alternate + /// version (local or linked). Items absent from the result have no alternate versions. + /// </summary> + /// <param name="itemIds">The item IDs to check.</param> + /// <returns>The set of item IDs that have alternate versions.</returns> + IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Creates or updates a LinkedChild entry linking a parent to a child item. /// </summary> /// <param name="parentId">The parent item ID.</param> @@ -598,6 +604,12 @@ namespace MediaBrowser.Controller.Library IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query); /// <summary> + /// Deletes every credit that no item maps to any more. + /// </summary> + /// <returns>The number of credits that were deleted.</returns> + int DeleteOrphanedCredits(); + + /// <summary> /// Gets the distinct people names per item for multiple items. /// </summary> /// <param name="itemIds">The item IDs.</param> @@ -606,6 +618,13 @@ namespace MediaBrowser.Controller.Library IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes); /// <summary> + /// Gets the people for multiple items in a single query, keyed by item id. + /// </summary> + /// <param name="itemIds">The item IDs.</param> + /// <returns>A dictionary mapping each item ID to its people. Items with no people are omitted.</returns> + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Queries the items. /// </summary> /// <param name="query">The query.</param> @@ -688,6 +707,14 @@ namespace MediaBrowser.Controller.Library /// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns> bool IgnoreFile(FileSystemMetadata file, BaseItem parent); + /// <summary> + /// Gets the id a <see cref="Person"/> item for the name would have, without looking it up + /// or creating it. + /// </summary> + /// <param name="name">The name of the person.</param> + /// <returns>The item id for the name.</returns> + Guid GetPersonId(string name); + Guid GetStudioId(string name); Guid GetGenreId(string name); @@ -733,13 +760,25 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user); /// <summary> + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the + /// whole batch; the rest fall back to one query per item. + /// </summary> + /// <param name="kind">The kind of the name items.</param> + /// <param name="ids">The IDs of the name items.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="user">The user for access filtering.</param> + /// <returns>The item counts of each requested id.</returns> + Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user); + + /// <summary> /// Batch-fetches child counts for multiple parent folders. /// Returns the count of immediate children (non-recursive) for each parent. /// </summary> /// <param name="parentIds">The list of parent folder IDs.</param> - /// <param name="userId">The user ID for access filtering.</param> + /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param> /// <returns>Dictionary mapping parent ID to child count.</returns> - Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user); /// <summary> /// Batch-fetches played and total counts for multiple folder items. @@ -794,10 +833,25 @@ namespace MediaBrowser.Controller.Library QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); /// <summary> + /// Gets a list of all distinct tags of the matching items. + /// </summary> + /// <param name="query">The query filter.</param> + /// <returns>List of tags.</returns> + IReadOnlyList<string> GetTagNames(InternalItemsQuery query); + + /// <summary> /// Gets a list of all language codes of the provided stream type. /// </summary> /// <param name="mediaStreamType">The stream type.</param> /// <returns>List of language codes.</returns> IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); + + /// <summary> + /// Gets a list of all language codes for the matching items and the the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The stream type.</param> + /// <param name="query">The query filter.</param> + /// <returns>List of language codes.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query); } } diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs index 845588c872..b1ff800fa0 100644 --- a/MediaBrowser.Controller/Library/SearchProviderQuery.cs +++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs @@ -19,7 +19,9 @@ public class SearchProviderQuery public Guid? UserId { get; init; } /// <summary> - /// Gets the item types to include in the search. + /// Gets the item types to include in the search. An empty array means every type is eligible. + /// When this is non-empty it is the authoritative type filter and <see cref="ExcludeItemTypes"/> + /// does not apply; excludes only take effect when no include types were requested. /// </summary> public BaseItemKind[] IncludeItemTypes { get; init; } = []; @@ -29,7 +31,9 @@ public class SearchProviderQuery public BaseItemKind[] ExcludeItemTypes { get; init; } = []; /// <summary> - /// Gets the media types to include in the search. + /// Gets the media types to include in the search. This is an additional constraint rather than + /// an alternative one: a provider must return only items that match both the requested media + /// types and the requested item types, not the union of the two. /// </summary> public MediaType[] MediaTypes { get; init; } = []; @@ -39,7 +43,9 @@ public class SearchProviderQuery public int? Limit { get; init; } /// <summary> - /// Gets the parent ID to scope the search. + /// Gets the parent ID to scope the search. This scopes to the whole subtree, not just direct + /// children - callers routinely pass a library folder id and expect items nested arbitrarily + /// deep beneath it (an episode under a season under a series) to match. /// </summary> public Guid? ParentId { get; init; } } diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs index 6da398129a..be75117b6f 100644 --- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs +++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs @@ -17,7 +17,8 @@ namespace MediaBrowser.Controller.LibraryTaskScheduler; /// </summary> public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable { - private const int CleanupGracePeriod = 60; + private static readonly TimeSpan _cleanupGracePeriod = TimeSpan.FromSeconds(60); + private readonly IHostApplicationLifetime _hostApplicationLifetime; private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger; private readonly IServerConfigurationManager _serverConfigurationManager; @@ -31,6 +32,8 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr private readonly Lock _taskLock = new(); private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>(); + private readonly CancellationTokenSource _disposeTokenSource = new(); + private readonly TimeSpan _gracePeriod; private volatile int _workCounter; private Task? _cleanupTask; @@ -46,10 +49,34 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr IHostApplicationLifetime hostApplicationLifetime, ILogger<LimitedConcurrencyLibraryScheduler> logger, IServerConfigurationManager serverConfigurationManager) + : this(hostApplicationLifetime, logger, serverConfigurationManager, _cleanupGracePeriod) + { + } + + internal LimitedConcurrencyLibraryScheduler( + IHostApplicationLifetime hostApplicationLifetime, + ILogger<LimitedConcurrencyLibraryScheduler> logger, + IServerConfigurationManager serverConfigurationManager, + TimeSpan gracePeriod) { _hostApplicationLifetime = hostApplicationLifetime; _logger = logger; _serverConfigurationManager = serverConfigurationManager; + _gracePeriod = gracePeriod; + } + + /// <summary> + /// Gets the number of runners the scheduler currently keeps alive. + /// </summary> + internal int ActiveRunnerCount + { + get + { + lock (_taskLock) + { + return _taskRunners.Count; + } + } } private void ScheduleTaskCleanup() @@ -68,31 +95,65 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr async Task RunCleanupTask() { - _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod); - await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false); - if (_disposed) + while (true) { - _logger.LogDebug("Abort cleaning up, already disposed."); - return; - } + _logger.LogDebug("Schedule cleanup task in {CleanupGracePeriod}.", _gracePeriod); + try + { + await Task.Delay(_gracePeriod, _disposeTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Abort cleaning up, already disposed."); + return; + } - lock (_taskLock) - { - if (_tasks.Reader.Count > 0 || _workCounter > 0) + if (_disposed) { - _logger.LogDebug("Delay cleanup task, operations still running."); - // tasks are still there so its still in use. Reschedule cleanup task. - // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended. - _cleanupTask = RunCleanupTask(); + _logger.LogDebug("Abort cleaning up, already disposed."); return; } + + CancellationTokenSource[] runners; + lock (_taskLock) + { + if (_tasks.Reader.Count > 0 || _workCounter > 0) + { + _logger.LogDebug("Delay cleanup task, operations still running."); + // tasks are still there so its still in use. Wait another grace period. + // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended. + continue; + } + + runners = [.. _taskRunners.Keys]; + + // Retire the runners before they are told to stop: an operation starting while + // they wind down must spawn its own instead of counting these towards the fanout. + _taskRunners.Clear(); + + // Hand the next operation the ability to schedule a cleanup again. Without this + // the very first cleanup would be the only one that ever runs. + _cleanupTask = null; + } + + _logger.LogDebug("Cleanup runners."); + await StopRunners(runners).ConfigureAwait(false); + return; } + } + } - _logger.LogDebug("Cleanup runners."); - foreach (var item in _taskRunners.ToArray()) + private static async Task StopRunners(CancellationTokenSource[] runners) + { + foreach (var runner in runners) + { + try { - await item.Key.CancelAsync().ConfigureAwait(false); - _taskRunners.Remove(item.Key); + await runner.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // The runner already stopped on its own and disposed its stop source. } } } @@ -127,12 +188,17 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr { var stopToken = new CancellationTokenSource(); var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping); + + // Keyed on its own stop source, because cancelling that is what reaches the linked + // source the runner waits on. Cancellation does not travel the other way. + // Started without the runner's own token: a task cancelled before it is scheduled + // never runs its body, so it would never take itself out of _taskRunners again. _taskRunners.Add( - combinedSource, + stopToken, Task.Factory.StartNew( ItemWorker, - (combinedSource, stopToken), - combinedSource.Token, + (stopToken, combinedSource), + CancellationToken.None, TaskCreationOptions.PreferFairness, TaskScheduler.Default)); } @@ -145,7 +211,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr _deadlockDetector.Value = stopToken.TaskStop; try { - while (!stopToken.GlobalStop.Token.IsCancellationRequested) + while (!stopToken.GlobalStop.IsCancellationRequested) { var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false); try @@ -162,15 +228,24 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr } } } - catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested) + catch (OperationCanceledException) when (stopToken.GlobalStop.IsCancellationRequested) { // thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose. } + catch (ChannelClosedException) + { + // the scheduler was disposed and will not hand out any more work. + } finally { _logger.LogDebug("Cleanup Runner'."); _deadlockDetector.Value = default!; - _taskRunners.Remove(stopToken.TaskStop); + + lock (_taskLock) + { + _taskRunners.Remove(stopToken.TaskStop); + } + stopToken.GlobalStop.Dispose(); stopToken.TaskStop.Dispose(); } @@ -195,7 +270,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr finally { item.Progress.Report(100); - item.Done.SetResult(); + item.Done.TrySetResult(); } } @@ -285,16 +360,33 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr _disposed = true; _tasks.Writer.Complete(); - foreach (var item in _taskRunners) + + // Nobody is left to run these, so release whoever is waiting on them. + while (_tasks.Reader.TryRead(out var item)) { - await item.Key.CancelAsync().ConfigureAwait(false); + item.Done.TrySetResult(); } - if (_cleanupTask is not null) + CancellationTokenSource[] runners; + Task? cleanupTask; + lock (_taskLock) { - await _cleanupTask.ConfigureAwait(false); - _cleanupTask?.Dispose(); + runners = [.. _taskRunners.Keys]; + _taskRunners.Clear(); + cleanupTask = _cleanupTask; } + + await StopRunners(runners).ConfigureAwait(false); + + // Cuts the grace period short instead of holding up shutdown for the rest of it. + await _disposeTokenSource.CancelAsync().ConfigureAwait(false); + + if (cleanupTask is not null) + { + await cleanupTask.ConfigureAwait(false); + } + + _disposeTokenSource.Dispose(); } private class TaskQueueItem diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 8d59eef9f1..77e0087048 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -201,6 +201,13 @@ namespace MediaBrowser.Controller.LiveTv IEnumerable<User> GetEnabledUsers(); /// <summary> + /// Gets whether Live TV is enabled for a single user. + /// </summary> + /// <param name="user">The user.</param> + /// <returns>Whether Live TV is enabled for the user.</returns> + bool IsEnabledForUser(User user); + + /// <summary> /// Gets the internal channels. /// </summary> /// <param name="query">The query.</param> diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 06188ad511..da2ffdeb79 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Controller</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> @@ -18,7 +18,6 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="BitFaster.Caching" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> </ItemGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 1b0bbe9ea0..6f010c0242 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -442,7 +442,8 @@ namespace MediaBrowser.Controller.MediaEncoding && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 || IsHdr10Plus(state.VideoStream) || IsDoviWithHdr10Bl(state.VideoStream) - || state.VideoStream.VideoRangeType == VideoRangeType.HLG); + || state.VideoStream.VideoRangeType == VideoRangeType.HLG + || state.VideoStream.VideoRangeType == VideoRangeType.DOVIInvalid); } private static bool IsDeinterlaceAvailable(EncodingJobInfo state) @@ -695,7 +696,11 @@ namespace MediaBrowser.Controller.MediaEncoding "ogg" or "oga" or "ogv" or "webm" or "webma" => "opus", "m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac", "ts" or "avi" or "flv" or "f4v" or "swf" => "mp3", - _ => inferredCodec + // Containers that share their name with the codec they carry. + "aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec, + // Anything else - manifests such as m3u8/mpd in particular - names a container that + // is not an audio codec. Never hand that name to ffmpeg as an encoder. + _ => "aac" }; } @@ -1318,7 +1323,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(canvasArgs); } - arg.Append(" -i file:\"").Append(subtitlePath).Append('\"'); + arg.Append(" -i file:\"").Append(subtitlePath.EscapeProcessArgument()).Append('\"'); } if (state.AudioStream is not null && state.AudioStream.IsExternal) @@ -1330,7 +1335,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekAudioParam); } - arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"'); + arg.Append(" -i \"").Append(state.AudioStream.Path.EscapeProcessArgument()).Append('"'); } // Disable auto inserted SW scaler for HW decoders in case of changed resolution. @@ -1386,7 +1391,8 @@ namespace MediaBrowser.Controller.MediaEncoding or VideoRangeType.DOVIWithEL or VideoRangeType.DOVIWithHDR10Plus or VideoRangeType.DOVIWithELHDR10Plus - or VideoRangeType.DOVIInvalid; + || (rangeType == VideoRangeType.DOVIInvalid + && string.Equals(stream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)); // invalid may be hlg now } public static bool IsDovi(MediaStream stream) @@ -1396,7 +1402,8 @@ namespace MediaBrowser.Controller.MediaEncoding return IsDoviWithHdr10Bl(stream) || (rangeType is VideoRangeType.DOVI or VideoRangeType.DOVIWithHLG - or VideoRangeType.DOVIWithSDR); + or VideoRangeType.DOVIWithSDR + or VideoRangeType.DOVIInvalid); } public static bool IsHdr10Plus(MediaStream stream) @@ -1416,7 +1423,8 @@ namespace MediaBrowser.Controller.MediaEncoding private static DynamicHdrMetadataRemovalPlan ShouldRemoveDynamicHdrMetadata(EncodingJobInfo state) { var videoStream = state.VideoStream; - if (videoStream.VideoRange is not VideoRange.HDR) + if (videoStream.VideoRange is not VideoRange.HDR + && videoStream.VideoRangeType != VideoRangeType.DOVIInvalid) { return DynamicHdrMetadataRemovalPlan.None; } @@ -3799,6 +3807,11 @@ namespace MediaBrowser.Controller.MediaEncoding var formatArg = isFormatFixed ? (":format=" + videoFormat) : string.Empty; var tonemapArg = string.Empty; + // libplacebo only support full range RGB + forceFullRange = forceFullRange + || (videoFormat ?? string.Empty).Contains("rgb", StringComparison.OrdinalIgnoreCase) + || (videoFormat ?? string.Empty).Contains("bgr", StringComparison.OrdinalIgnoreCase); + if (doTonemap) { var algorithm = options.TonemappingAlgorithm; @@ -3822,6 +3835,10 @@ namespace MediaBrowser.Controller.MediaEncoding tonemapArg += ":range=" + range.ToString().ToLowerInvariant(); } } + else if (forceFullRange) + { + formatArg += ":range=pc"; + } return string.Format( CultureInfo.InvariantCulture, @@ -5462,7 +5479,14 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add("format=vaapi"); // clear the surf->meta_offset and output nv12 - mainFilters.Add("scale_vaapi=format=nv12"); + var hwCscFilter = "scale_vaapi=format=nv12"; + + if (!isMjpegEncoder && options.TonemappingRange != TonemappingRange.pc) + { + hwCscFilter += ":out_range=tv"; + } + + mainFilters.Add(hwCscFilter); // hw deint if (doDeintH2645) @@ -5532,7 +5556,14 @@ namespace MediaBrowser.Controller.MediaEncoding overlayFilters.Add("format=vaapi"); // clear the surf->meta_offset and output nv12 - overlayFilters.Add("scale_vaapi=format=nv12"); + var hwCscFilter = "scale_vaapi=format=nv12"; + + if (!doVkTonemap || (doVkTonemap && options.TonemappingRange != TonemappingRange.pc)) + { + hwCscFilter += ":out_range=tv"; + } + + overlayFilters.Add(hwCscFilter); // hw deint if (doDeintH2645) @@ -6311,7 +6342,7 @@ namespace MediaBrowser.Controller.MediaEncoding string.Join(',', overlayFilters)); var mapPrefix = Convert.ToInt32(state.SubtitleStream.IsExternal); - var subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream); + var subtitleStreamIndex = GetSubtitleStreamIndexForFfmpeg(state.MediaSource, state.SubtitleStream); var videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream); if (hasSubs) @@ -7864,10 +7895,16 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state)); } - if (GetAudioEncoder(state).StartsWith("pcm_", StringComparison.Ordinal)) + // The pcm_* encoders emit raw samples that carry no header of their own, so the header + // has to come from the muxer. Only force the matching raw muxer when the client actually + // asked for a raw container (added in #10321 for I2S/MCU clients): applying it to every + // pcm_* codec also strips the RIFF header from a `stream.wav` request, which then serves + // headerless PCM behind an audio/wav content type. + var audioEncoder = GetAudioEncoder(state); + if (audioEncoder.StartsWith("pcm_", StringComparison.Ordinal) + && string.Equals(state.OutputContainer, "pcm", StringComparison.OrdinalIgnoreCase)) { - audioTranscodeParams.Add(string.Concat("-f ", GetAudioEncoder(state).AsSpan(4))); - audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); + audioTranscodeParams.Add(string.Concat("-f ", audioEncoder.AsSpan(4))); } var sampleRate = state.OutputAudioSampleRate; @@ -7937,6 +7974,24 @@ namespace MediaBrowser.Controller.MediaEncoding return -1; } + public static int GetSubtitleStreamIndexForFfmpeg(MediaSourceInfo mediaSource, MediaStream subtitleStream) + { + var index = FindIndex(mediaSource.MediaStreams, subtitleStream); + if (index == -1 || subtitleStream.IsExternal || mediaSource.VideoType != VideoType.BluRay) + { + return index; + } + + var hiddenStreamsBefore = mediaSource.MediaStreams.Count(s => + s.Type == MediaStreamType.Audio + && !s.IsExternal + && (string.Equals(s.Codec, "truehd", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.Codec, "atmos", StringComparison.OrdinalIgnoreCase)) + && s.Index < subtitleStream.Index); + + return index + hiddenStreamsBefore; + } + public static bool IsCopyCodec(string codec) { return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase); diff --git a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs index 56990d0b82..5045030b9b 100644 --- a/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs +++ b/MediaBrowser.Controller/MediaEncoding/TranscodingJob.cs @@ -15,6 +15,7 @@ public sealed class TranscodingJob : IDisposable private readonly Lock _processLock = new(); private readonly Lock _timerLock = new(); + private int _activeRequestCount; private Timer? _killTimer; /// <summary> @@ -64,7 +65,11 @@ public sealed class TranscodingJob : IDisposable /// <summary> /// Gets or sets the active request count. /// </summary> - public int ActiveRequestCount { get; set; } + public int ActiveRequestCount + { + get => Volatile.Read(ref _activeRequestCount); + set => Volatile.Write(ref _activeRequestCount, value); + } /// <summary> /// Gets or sets device id. @@ -152,6 +157,20 @@ public sealed class TranscodingJob : IDisposable public int PingTimeout { get; set; } /// <summary> + /// Increments the active request count. + /// </summary> + /// <returns>The incremented count.</returns> + public int IncrementActiveRequestCount() + => Interlocked.Increment(ref _activeRequestCount); + + /// <summary> + /// Decrements the active request count. + /// </summary> + /// <returns>The decremented count.</returns> + public int DecrementActiveRequestCount() + => Interlocked.Decrement(ref _activeRequestCount); + + /// <summary> /// Stop kill timer. /// </summary> public void StopKillTimer() diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs index ca55340a05..50ed76f762 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs @@ -4,7 +4,9 @@ using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> -/// Force keep alive websocket messages. +/// Force keep alive websocket messages. The data is the timeout in seconds after which the +/// server considers the connection lost; clients are expected to answer with a KeepAlive +/// message and to keep sending one at least every half of that timeout. /// </summary> public class ForceKeepAliveMessage : OutboundWebSocketMessage<int> { diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs index d57f1fc893..14c422cb60 100644 --- a/MediaBrowser.Controller/Persistence/IItemCountService.cs +++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs @@ -37,6 +37,18 @@ public interface IItemCountService ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); /// <summary> + /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned + /// item value - artists, genres and studios - are answered in one set of queries for the whole + /// batch; the rest fall back to one query per id. + /// </summary> + /// <param name="kind">The kind of the name items.</param> + /// <param name="ids">The IDs of the name items.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="accessFilter">A pre-configured query with user access filtering settings.</param> + /// <returns>The item counts of each requested id.</returns> + Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); + + /// <summary> /// Gets the count of played items that are descendants of the specified ancestor. /// </summary> /// <param name="filter">The query filter containing user access settings.</param> @@ -80,7 +92,7 @@ public interface IItemCountService /// Batch-fetches child counts for multiple parent folders. /// </summary> /// <param name="parentIds">The list of parent folder IDs.</param> - /// <param name="userId">The user ID for access filtering.</param> + /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param> /// <returns>Dictionary mapping parent ID to child count.</returns> - Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user); } diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index 2e29cbdbba..f9a050d591 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -79,17 +80,26 @@ public interface IItemQueryHelpers Guid ancestorId); /// <summary> - /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played - /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// Builds a query for the playable leaf items a user can access. /// </summary> /// <param name="context">The database context the resulting query is bound to.</param> - /// <param name="folderIds">A query yielding candidate folder IDs.</param> - /// <param name="user">The user for access filtering and played status.</param> - /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> - IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + /// <param name="user">The user to filter accessible items for.</param> + /// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param> + /// <returns>The access-filtered leaf item queryable.</returns> + IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery( JellyfinDbContext context, - IQueryable<Guid> folderIds, - User user); + User user, + bool includeOwnedItems = false); + + /// <summary> + /// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them. + /// </summary> + /// <param name="context">The database context the resulting filter is bound to.</param> + /// <param name="descendants">A query yielding the descendants to look for.</param> + /// <returns>A filter expression matching items with a matching descendant.</returns> + Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter( + JellyfinDbContext context, + IQueryable<BaseItemEntity> descendants); /// <summary> /// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>. diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 291916ab25..3cf06b897c 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -7,6 +7,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Persistence; @@ -120,6 +121,21 @@ public interface IItemRepository IReadOnlyList<string> GetGenreNames(); /// <summary> + /// Gets all language codes of the matching base items and the provided stream type. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <param name="mediaStreamType">The type of the media stream.</param> + /// <returns>List of language codes.</returns> + public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType); + + /// <summary> + /// Gets all distinct tags of the matching base items. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The list of tags.</returns> + IReadOnlyList<string> GetTagNames(InternalItemsQuery filter); + + /// <summary> /// Gets all artist names. /// </summary> /// <returns>The list of artist names.</returns> diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs index a4614fc125..79c29410e4 100644 --- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -20,6 +20,15 @@ public interface ILinkedChildrenService IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null); /// <summary> + /// Gets, in a single query, the subset of the supplied items that own at least one alternate + /// version (local or linked). Items absent from the result have no alternate versions, so their + /// media source count is one. + /// </summary> + /// <param name="itemIds">The item IDs to check.</param> + /// <returns>The set of item IDs that have alternate versions.</returns> + IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds); + + /// <summary> /// Gets all artist matches from the database. /// </summary> /// <param name="artistNames">The names of the artists.</param> diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index e2833dc722..15183a8806 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -34,10 +34,23 @@ public interface IPeopleRepository IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter); /// <summary> + /// Deletes every credit that no item maps to any more. + /// </summary> + /// <returns>The number of credits that were deleted.</returns> + int DeleteOrphanedCredits(); + + /// <summary> /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table. /// </summary> /// <param name="itemIds">The item IDs to get people for.</param> /// <param name="personTypes">The person types to include (e.g. "Actor", "Director").</param> /// <returns>A dictionary mapping each item ID to its distinct people names, ordered by cast list order. Items with no matching people are omitted.</returns> IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes); + + /// <summary> + /// Gets the people for multiple items in a single query, keyed by item id. + /// </summary> + /// <param name="itemIds">The item IDs to get people for.</param> + /// <returns>A dictionary mapping each item ID to its people (with role, type and sort order), ordered by cast list order. Items with no people are omitted.</returns> + IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds); } diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index fc367b8293..edf3fb25c9 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -235,18 +235,18 @@ namespace MediaBrowser.Controller.Playlists { if (!IsSharedItem) { - return base.IsVisible(user, skipAllowedTagsCheck); + return base.IsVisible(user, skipAllowedTagsCheck) && HasParentalAllowedChild(user); } if (OpenAccess) { - return true; + return HasParentalAllowedChild(user); } var userId = user.Id; if (userId.Equals(OwnerUserId)) { - return true; + return HasParentalAllowedChild(user); } var shares = Shares; @@ -255,7 +255,19 @@ namespace MediaBrowser.Controller.Playlists return false; } - return shares.Any(s => s.UserId.Equals(userId)); + return shares.Any(s => s.UserId.Equals(userId)) && HasParentalAllowedChild(user); + } + + private bool HasParentalAllowedChild(User user) + { + if (!user.MaxParentalRatingScore.HasValue) + { + return true; + } + + var linkedItems = GetLinkedChildren(); + + return linkedItems.Count == 0 || linkedItems.Any(child => child.IsParentalAllowed(user, true)); } public override bool CanDelete(User user) diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 6060d051a5..f8e0bf4ed9 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -5,13 +5,19 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using MediaBrowser.Model.IO; namespace MediaBrowser.Controller.Providers { public class DirectoryService : IDirectoryService { - // TODO make static and switch to FastConcurrentLru. + // TODO replace with one shared bounded cache. + private const int MaxCachedRecords = 100_000; + private const int AccessIntervalMs = 1_000; + // Timeout cache if no access for 5 minutes. + private const int IdleTimeoutMs = 5 * 60 * 1_000; + private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new(StringComparer.Ordinal); @@ -20,6 +26,12 @@ namespace MediaBrowser.Controller.Providers private readonly IFileSystem _fileSystem; + // ConcurrentDictionary.Count locks the dictionary, so keep an estimated counter. + // Concurrent factory runs can overcount and a clear racing an add can undercount, + // it only has to be roughly right. + private int _recordCount; + private long _lastAccess = Environment.TickCount64; + public DirectoryService(IFileSystem fileSystem) { _fileSystem = fileSystem; @@ -27,20 +39,26 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata[] GetFileSystemEntries(string path) { + DropCacheIfIdleOrFull(); + return _cache.GetOrAdd( path, - static (p, fileSystem) => + static (p, state) => { + FileSystemMetadata[] entries; try { - return fileSystem.GetFileSystemEntries(p).ToArray(); + entries = state.FileSystem.GetFileSystemEntries(p).ToArray(); } catch (DirectoryNotFoundException) { - return []; + entries = []; } + + Interlocked.Add(ref state.Service._recordCount, entries.Length + 1); + return entries; }, - _fileSystem); + (FileSystem: _fileSystem, Service: this)); } public List<FileSystemMetadata> GetDirectories(string path) @@ -89,13 +107,18 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata? GetFileSystemEntry(string path) { + DropCacheIfIdleOrFull(); + if (!_fileCache.TryGetValue(path, out var result)) { var file = _fileSystem.GetFileSystemInfo(path); if (file?.Exists ?? false) { result = file; - _fileCache.TryAdd(path, result); + if (_fileCache.TryAdd(path, result)) + { + Interlocked.Increment(ref _recordCount); + } } } @@ -107,32 +130,96 @@ namespace MediaBrowser.Controller.Providers public IReadOnlyList<string> GetFilePaths(string path, bool clearCache) { - if (clearCache) + if (clearCache && _filePathCache.TryRemove(path, out var cached)) { - _filePathCache.TryRemove(path, out _); + Interlocked.Add(ref _recordCount, -(cached.Count + 1)); } + DropCacheIfIdleOrFull(); + var filePaths = _filePathCache.GetOrAdd( path, - static (p, fileSystem) => + static (p, state) => { + List<string> filePaths; try { - return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); + filePaths = state.FileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); } catch (DirectoryNotFoundException) { - return []; + filePaths = []; } + + Interlocked.Add(ref state.Service._recordCount, filePaths.Count + 1); + return filePaths; }, - _fileSystem); + (FileSystem: _fileSystem, Service: this)); return filePaths; } + public void Invalidate(string path) + { + Forget(path); + + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Forget(parent); + } + } + + public void Move(string source, string destination) + { + Directory.Move(source, destination); + + Invalidate(source); + Invalidate(destination); + } + public bool IsAccessible(string path) { return _fileSystem.GetFileSystemEntryPaths(path).Any(); } + + private void DropCacheIfIdleOrFull() + { + var nowMs = Environment.TickCount64; + var idleMs = nowMs - _lastAccess; + + if (idleMs >= IdleTimeoutMs || _recordCount >= MaxCachedRecords) + { + _cache.Clear(); + _fileCache.Clear(); + _filePathCache.Clear(); + _recordCount = 0; + _lastAccess = nowMs; + return; + } + + if (idleMs >= AccessIntervalMs) + { + _lastAccess = nowMs; + } + } + + private void Forget(string path) + { + if (_cache.TryRemove(path, out var entries)) + { + Interlocked.Add(ref _recordCount, -(entries.Length + 1)); + } + + if (_fileCache.TryRemove(path, out _)) + { + Interlocked.Decrement(ref _recordCount); + } + + if (_filePathCache.TryRemove(path, out var filePaths)) + { + Interlocked.Add(ref _recordCount, -(filePaths.Count + 1)); + } + } } } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 8a3fa33da3..3a943d5f0c 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -23,6 +23,19 @@ namespace MediaBrowser.Controller.Providers IReadOnlyList<string> GetFilePaths(string path, bool clearCache); + /// <summary> + /// Forgets what is cached about a path and about the directory containing it. + /// </summary> + /// <param name="path">The file or directory path that changed.</param> + void Invalidate(string path); + + /// <summary> + /// Moves a directory and forgets what is cached about both paths. + /// </summary> + /// <param name="source">The directory to move.</param> + /// <param name="destination">The path to move the directory to.</param> + void Move(string source, string destination); + bool IsAccessible(string path); } } diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index ef69885fcf..48fc22a0fb 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers private List<(string Url, ImageType Type)> _remoteImages; private List<PersonInfo> _people; - public MetadataResult() - { - ResultLanguage = "en"; - } - public List<LocalImageInfo> Images { get => _images ??= []; @@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers public T Item { get; set; } + /// <summary> + /// Gets or sets the language the fetched metadata is in. + /// </summary> public string ResultLanguage { get; set; } public string Provider { get; set; } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index c11c65c334..9acff745b9 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -238,23 +238,26 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Adds the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - void AddAdditionalUser(string sessionId, Guid userId); + void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId); /// <summary> /// Removes the additional user. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="userId">The user identifier.</param> - void RemoveAdditionalUser(string sessionId, Guid userId); + void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId); /// <summary> /// Reports the now viewing item. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="itemId">The item identifier.</param> - void ReportNowViewingItem(string sessionId, string itemId); + void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId); /// <summary> /// Authenticates the new session. @@ -268,9 +271,10 @@ namespace MediaBrowser.Controller.Session /// <summary> /// Reports the capabilities. /// </summary> + /// <param name="controllingSessionId">The controlling session identifier.</param> /// <param name="sessionId">The session identifier.</param> /// <param name="capabilities">The capabilities.</param> - void ReportCapabilities(string sessionId, ClientCapabilities capabilities); + void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities); /// <summary> /// Reports the transcoding information. diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index fb68bfb770..d9bc79dec5 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -28,6 +28,7 @@ namespace MediaBrowser.Controller.Session private readonly Lock _progressLock = new(); private Timer _progressTimer; private PlaybackProgressInfo _lastProgressInfo; + private long? _lastPlaybackCheckInPositionTicks; private bool _disposed; @@ -125,6 +126,22 @@ namespace MediaBrowser.Controller.Session public DateTime LastPlaybackCheckIn { get; set; } /// <summary> + /// Gets the position reported by the client at the last playback check-in. + /// </summary> + /// <value>The position ticks, or <see langword="null"/> if the client did not report a position.</value> + [JsonIgnore] + public long? LastPlaybackCheckInPositionTicks + { + get + { + lock (_progressLock) + { + return _lastPlaybackCheckInPositionTicks; + } + } + } + + /// <summary> /// Gets or sets the last paused date. /// </summary> /// <value>The last paused date.</value> @@ -372,6 +389,7 @@ namespace MediaBrowser.Controller.Session lock (_progressLock) { + _lastPlaybackCheckInPositionTicks = progressInfo.PositionTicks; _lastProgressInfo = progressInfo; if (_progressTimer is null) diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index eb38eeb503..8f17039ae1 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -50,6 +50,11 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates /// </summary> private GroupStateType InitialState { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the group position moved during this wait. + /// </summary> + private bool PositionJumped { get; set; } + /// <inheritdoc /> public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) { @@ -136,6 +141,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates ResumePlaying = true; var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks); + PositionJumped = setQueueStatus; if (!setQueueStatus) { _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); @@ -175,6 +181,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates ResumePlaying = true; var result = context.SetPlayingItem(request.PlaylistItemId); + PositionJumped = result; if (result) { var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); @@ -214,6 +221,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { ResumePlaying = true; context.RestartCurrentItem(); + PositionJumped = true; var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate); @@ -310,6 +318,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates // Seek. context.PositionTicks = ticks; context.LastActivity = DateTime.UtcNow; + PositionJumped = true; var command = context.NewSyncPlayCommand(SendCommandType.Seek); context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); @@ -450,7 +459,13 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Handle case where session reported as ready but in reality // it has no clue of the real position nor the playback state. - if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks) + // A jump means the session has not applied the new position; without one it is + // catching up after buffering and is allowed to lag. + var maxOffsetTicks = request.IsPlaying && !PositionJumped + ? TimeSpan.FromMilliseconds(context.MaxCatchUpOffset).Ticks + : maxPlaybackOffsetTicks; + + if (Math.Abs(delayTicks) > maxOffsetTicks) { // Session not ready at all. context.SetBuffering(session, true); @@ -501,7 +516,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates { // Client, that was buffering, resumed playback but did not update others in time. delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; - delayTicks = Math.Max(delayTicks, context.DefaultPing); + delayTicks = Math.Max(delayTicks, TimeSpan.FromMilliseconds(context.DefaultPing).Ticks); context.LastActivity = currentTime.AddTicks(delayTicks); @@ -580,6 +595,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var newItem = context.NextItemInQueue(); + PositionJumped = newItem; if (newItem) { // Send playing-queue update. @@ -626,6 +642,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates } var newItem = context.PreviousItemInQueue(); + PositionJumped = newItem; if (newItem) { // Send playing-queue update. diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs index ddf86be71f..e02d1bde45 100644 --- a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -34,6 +34,12 @@ namespace MediaBrowser.Controller.SyncPlay long MaxPlaybackOffset { get; } /// <summary> + /// Gets the maximum offset accepted for a session catching up after buffering, in milliseconds. + /// </summary> + /// <value>The maximum catch-up offset, in milliseconds.</value> + long MaxCatchUpOffset => 60000; + + /// <summary> /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs index c0a168192e..258b92e4d9 100644 --- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -157,7 +157,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// </summary> public void RestoreSortedPlaylist() { - if (PlayingItemIndex != NoPlayingItemIndex) + // The shuffled playlist is only populated while the shuffle mode is active, so there is + // nothing to map back when the playlist is already sorted. Guarding on its contents keeps + // a redundant request for the sorted mode from indexing an empty list. + if (PlayingItemIndex != NoPlayingItemIndex && _shuffledPlaylist.Count > 0) { var playingItem = _shuffledPlaylist[PlayingItemIndex]; PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem); @@ -272,7 +275,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue public void SetPlayingItemByIndex(int playlistIndex) { var playlist = GetPlaylistInternal(); - if (playlistIndex < 0 || playlistIndex > playlist.Count) + if (playlistIndex < 0 || playlistIndex >= playlist.Count) { PlayingItemIndex = NoPlayingItemIndex; } @@ -293,6 +296,15 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { var playingItem = GetPlayingItem(); + // Removed items that precede the playing item shift its index as well. + var removedBeforePlayingItem = 0; + if (playingItem is not null) + { + removedBeforePlayingItem = GetPlaylistInternal() + .Take(PlayingItemIndex) + .Count(item => playlistItemIds.Contains(item.PlaylistItemId)); + } + _sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); _shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); @@ -303,12 +315,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue if (playlistItemIds.Contains(playingItem.PlaylistItemId)) { // Playing item has been removed, picking previous item. - PlayingItemIndex--; + PlayingItemIndex -= removedBeforePlayingItem + 1; if (PlayingItemIndex < 0) { // Was first element, picking next if available. // Default to no playing item otherwise. - PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex; + PlayingItemIndex = GetPlaylistInternal().Count > 0 ? 0 : NoPlayingItemIndex; } return true; @@ -444,6 +456,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> public bool Next() { + if (GetPlaylistInternal().Count == 0) + { + return false; + } + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) { LastChange = DateTime.UtcNow; @@ -474,6 +491,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> public bool Previous() { + if (GetPlaylistInternal().Count == 0) + { + return false; + } + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) { LastChange = DateTime.UtcNow; diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index b0f51aec71..d5e233d4f8 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Xml; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; @@ -277,7 +278,7 @@ namespace MediaBrowser.LocalMetadata.Savers await writer.WriteElementStringAsync(null, "Rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); } - if (item.ProductionYear.HasValue && item is not Person) + if (item.ProductionYear is not null && item is not Person) { await writer.WriteElementStringAsync(null, "ProductionYear", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); } @@ -500,7 +501,8 @@ namespace MediaBrowser.LocalMetadata.Savers { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToResolve] + ItemIds = [.. idsToResolve], + DtoOptions = DtoOptions.StoredColumnsOnly }); pathById = new Dictionary<Guid, string?>(batched.Count); foreach (var batchedItem in batched) diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 9dd3dcecba..fbe8afc66e 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -8,12 +8,12 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -101,7 +101,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CancellationToken cancellationToken) { var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName) - && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); + && !string.Equals(PathHelper.GetSafeLeafFileName(a.FileName), a.FileName, StringComparison.Ordinal)); if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { await ExtractAllAttachmentsIndividuallyInternal( @@ -159,7 +159,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CultureInfo.InvariantCulture, "-dump_attachment:{0} \"{1}\" ", attachment.Index, - EncodingUtils.NormalizePath(attachmentPath)); + attachmentPath.EscapeProcessArgument()); missingPaths.Add(attachmentPath); } @@ -387,7 +387,9 @@ namespace MediaBrowser.MediaEncoding.Attachments using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) { - var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!; + var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName) + ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!; if (!File.Exists(attachmentPath)) { await ExtractAttachmentInternal( @@ -422,7 +424,7 @@ namespace MediaBrowser.MediaEncoding.Attachments "-dump_attachment:{1} \"{2}\" -i {0} {3}", inputPath, attachmentStreamIndex, - EncodingUtils.NormalizePath(outputPath), + outputPath.EscapeProcessArgument(), hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); int exitCode; diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs index 6ca994fb7e..57c553386a 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -87,7 +87,7 @@ public class BdInfoExaminer : IBlurayExaminer if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0) { // Get the files in the playlist - outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.FileInfo.FullName).ToArray(); + outputStream.Files = playlist.StreamClips.Where(i => i.AngleIndex == 0).Select(i => i.StreamFile.FileInfo.FullName).ToArray(); } return outputStream; diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index c8670c67cc..99c08eb9a1 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; @@ -185,16 +186,13 @@ namespace MediaBrowser.MediaEncoding.Encoder { "libavdevice", new Version(58, 13) }, { "libavfilter", new Version(7, 110) }, { "libswscale", new Version(5, 9) }, - { "libswresample", new Version(3, 9) }, - { "libpostproc", new Version(55, 9) } + { "libswresample", new Version(3, 9) } }; private readonly ILogger _logger; private readonly string _encoderPath; - private readonly Version _minFFmpegMultiThreadedCli = new Version(7, 0); - public EncoderValidator(ILogger logger, string encoderPath) { _logger = logger; @@ -552,9 +550,9 @@ namespace MediaBrowser.MediaEncoding.Encoder string output; try { - // With multi-threaded cli support, FFmpeg 7 is less sensitive to keyboard input - var duration = ffmpegVersion >= _minFFmpegMultiThreadedCli ? 10000 : 1000; - output = GetProcessOutput(_encoderPath, $"-hide_banner -f lavfi -i nullsrc=s=1x1:d={duration} -f null -", true, "?"); + // Start a dummy encode of 1x1@1fps. Send '?' to stdin to get the help/keybind text, followed by 'q' to stop the job immediately + // As a safeguard in case 'q' doesn't stop the job, the dummy input has a max duration of 5 (realtime) seconds + output = GetProcessOutput(_encoderPath, $"-hide_banner -re -f lavfi -i nullsrc=s=1x1:r=1:d=5 -f null -", true, "?q"); } catch (Exception ex) { @@ -663,8 +661,15 @@ namespace MediaBrowser.MediaEncoding.Encoder writer.Write(testKey); } - using var reader = readStdErr ? process.StandardError : process.StandardOutput; - return reader.ReadToEnd(); + // Drain both streams concurrently to prevent pipe hanging, see #17429 + using var standardOutput = process.StandardOutput; + using var standardError = process.StandardError; + var standardOutputTask = standardOutput.ReadToEndAsync(); + var standardErrorTask = standardError.ReadToEndAsync(); + process.WaitForExit(); + Task.WaitAll(standardOutputTask, standardErrorTask); + + return (readStdErr ? standardErrorTask : standardOutputTask).GetAwaiter().GetResult(); } } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs index 2daeac7343..a525dcfa62 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.MediaEncoding.Encoder @@ -42,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // If there's more than one we'll need to use the concat command if (inputFiles.Count > 1) { - var files = string.Join('|', inputFiles.Select(NormalizePath)); + var files = string.Join('|', inputFiles.Select(f => f.EscapeProcessArgument())); return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files); } @@ -64,21 +65,9 @@ namespace MediaBrowser.MediaEncoding.Encoder return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", path); } - // Quotes are valid path characters in linux and they need to be escaped here with a leading \ - path = NormalizePath(path); + path = path.EscapeProcessArgument(); return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix); } - - /// <summary> - /// Normalizes the path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns>System.String.</returns> - public static string NormalizePath(string path) - { - // Quotes are valid path characters in linux and they need to be escaped here with a leading \ - return path.Replace("\"", "\\\"", StringComparison.Ordinal); - } } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 1199fd7d70..fa43d756a4 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -922,12 +922,40 @@ namespace MediaBrowser.MediaEncoding.Encoder inputArg = "-hwaccel_flags +low_priority " + inputArg; } + // Force the video stream, otherwise ffmpeg may pick a cover image. + var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, imageStream); + if (streamIndex < 0) + { + throw new InvalidOperationException($"Unable to locate requested stream {imageStream.Title}"); + } + + inputArg += " -map 0:" + streamIndex; + var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim(); if (string.IsNullOrWhiteSpace(filterParam)) { throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters."); } + // Normalize invalid PTS from containers for non keyframe only mode + if (!enableKeyFrameOnlyExtraction) + { + var fpsFilterIndex = filterParam.IndexOf("fps=", StringComparison.Ordinal); + if (fpsFilterIndex >= 0) + { + var inputFrameRate = (imageStream.ReferenceFrameRate.HasValue && imageStream.ReferenceFrameRate > 0) + ? imageStream.ReferenceFrameRate.Value : 30; + + var setPtsFilter = string.Create(CultureInfo.InvariantCulture, $"setpts=N/{inputFrameRate:F3}/TB,"); + + filterParam = filterParam.Insert(fpsFilterIndex, setPtsFilter); + } + else + { + throw new InvalidOperationException("EncodingHelper returned invalid filter parameters."); + } + } + try { return await ExtractVideoImagesOnIntervalInternal( @@ -1133,6 +1161,11 @@ namespace MediaBrowser.MediaEncoding.Encoder { process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; } + catch (InvalidOperationException) + { + // The process finished before its priority could be lowered. That says nothing + // about whether the platform allows it, so keep the capability for the next one. + } catch (Exception ex) { _canSetProcessPriority = false; @@ -1342,12 +1375,20 @@ namespace MediaBrowser.MediaEncoding.Encoder return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction; } - private sealed class ProcessWrapper : IDisposable + internal sealed class ProcessWrapper : IDisposable { private readonly MediaEncoder _mediaEncoder; + // The exit event is raised on the thread pool, so it writes the state below while the + // caller that started the process is reading it. + private readonly Lock _exitLock = new(); + private bool _disposed = false; + private bool _hasExited; + + private int? _exitCode; + public ProcessWrapper(Process process, MediaEncoder mediaEncoder) { Process = process; @@ -1357,49 +1398,84 @@ namespace MediaBrowser.MediaEncoding.Encoder public Process Process { get; } - public bool HasExited { get; private set; } + // The exit event can lag behind the wait that returned, so ask the process rather than + // report one that has exited as still running. + public bool HasExited => ReadExitState().HasExited; + + // As above: rather than report no exit code for a process that has one. + public int? ExitCode => ReadExitState().ExitCode; + + private (bool HasExited, int? ExitCode) ReadExitState() + { + lock (_exitLock) + { + if (!_hasExited && !_disposed) + { + try + { + if (Process.HasExited) + { + _hasExited = true; + _exitCode = Process.ExitCode; + } + } + catch (InvalidOperationException) + { + // No process is associated with this object, or it was disposed from + // under us - ObjectDisposedException derives from this one. + } + } - public int? ExitCode { get; private set; } + return (_hasExited, _exitCode); + } + } private void OnProcessExited(object sender, EventArgs e) { var process = (Process)sender; - HasExited = true; - - try - { - ExitCode = process.ExitCode; - } - catch + lock (_exitLock) { + _hasExited = true; + + try + { + _exitCode = process.ExitCode; + } + catch + { + } } - DisposeProcess(process); + // Only stop tracking it. The caller that started the process still holds it to read + // its output and its exit code, so disposing it here handed whoever was quickest to + // exit - an ffprobe on a file it rejects outright - an ObjectDisposedException. + Untrack(); } - private void DisposeProcess(Process process) + private void Untrack() { lock (_mediaEncoder._runningProcessesLock) { _mediaEncoder._runningProcesses.Remove(this); } - - process.Dispose(); } public void Dispose() { - if (!_disposed) + lock (_exitLock) { - if (Process is not null) + if (_disposed) { - Process.Exited -= OnProcessExited; - DisposeProcess(Process); + return; } + + _disposed = true; } - _disposed = true; + Process.Exited -= OnProcessExited; + Untrack(); + Process.Dispose(); } } } diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 203e565ac7..5533e55223 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -189,7 +189,7 @@ namespace MediaBrowser.MediaEncoding.Probing } // Guess ProductionYear from PremiereDate if missing - if (!info.ProductionYear.HasValue && info.PremiereDate.HasValue) + if (info.ProductionYear is null && info.PremiereDate is not null) { info.ProductionYear = info.PremiereDate.Value.Year; } @@ -732,9 +732,10 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedOriginal = _localization.GetLocalizedString("Original"); - stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) - ? null - : _localization.FindLanguageInfo(stream.Language)?.DisplayName; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } stream.Channels = streamInfo.Channels; @@ -756,11 +757,17 @@ namespace MediaBrowser.MediaEncoding.Probing if (string.IsNullOrEmpty(stream.Title)) { - // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SoundHandler" - string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); - if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase)) + // FFprobe exposes MP4 track names via the name tag rather than title + stream.Title = GetDictionaryValue(streamInfo.Tags, "name"); + + if (string.IsNullOrEmpty(stream.Title)) { - stream.Title = handlerName; + // fall back to handler_name if populated and not the default "SoundHandler" + string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); + if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase)) + { + stream.Title = handlerName; + } } } } @@ -773,17 +780,24 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedForced = _localization.GetLocalizedString("Forced"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); - stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) - ? null - : _localization.FindLanguageInfo(stream.Language)?.DisplayName; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } if (string.IsNullOrEmpty(stream.Title)) { - // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler" - string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); - if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase)) + // FFprobe exposes MP4 track names via the name tag rather than title + stream.Title = GetDictionaryValue(streamInfo.Tags, "name"); + + if (string.IsNullOrEmpty(stream.Title)) { - stream.Title = handlerName; + // fall back to handler_name if populated and not the default "SubtitleHandler" + string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name"); + if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase)) + { + stream.Title = handlerName; + } } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs index bd13437fb6..7566616f70 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System.IO; -using MediaBrowser.Model.MediaInfo; +using Nikse.SubtitleEdit.Core.Common; namespace MediaBrowser.MediaEncoding.Subtitles { @@ -12,8 +12,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// </summary> /// <param name="stream">The stream.</param> /// <param name="fileExtension">The file extension.</param> - /// <returns>SubtitleTrackInfo.</returns> - SubtitleTrackInfo Parse(Stream stream, string fileExtension); + /// <returns>The parsed subtitle.</returns> + Subtitle Parse(Stream stream, string fileExtension); /// <summary> /// Determines whether the file extension is supported by the parser. diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs index d060b247da..d75eea5904 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using Jellyfin.Extensions; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; using Nikse.SubtitleEdit.Core.Common; using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat; @@ -30,7 +28,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } /// <inheritdoc /> - public SubtitleTrackInfo Parse(Stream stream, string fileExtension) + public Subtitle Parse(Stream stream, string fileExtension) { var subtitle = new Subtitle(); var lines = stream.ReadAllLines().ToList(); @@ -76,21 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new ArgumentException("Unsupported format: " + fileExtension); } - var trackInfo = new SubtitleTrackInfo(); - int len = subtitle.Paragraphs.Count; - var trackEvents = new SubtitleTrackEvent[len]; - for (int i = 0; i < len; i++) - { - var p = subtitle.Paragraphs[i]; - trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text) - { - StartPositionTicks = p.StartTime.TimeSpan.Ticks, - EndPositionTicks = p.EndTime.TimeSpan.Ticks - }; - } - - trackInfo.TrackEvents = trackEvents; - return trackInfo; + return subtitle; } /// <inheritdoc /> diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 28f7b92268..fba644b74a 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -12,6 +12,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -73,7 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles _serverConfigurationManager = serverConfigurationManager; } - private MemoryStream ConvertSubtitles( + internal MemoryStream ConvertSubtitles( Stream stream, SubtitleInfo inputInfo, string outputFormat, @@ -81,7 +82,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles long endTimeTicks, bool preserveOriginalTimestamps) { - var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); + var subtitle = _subtitleParser.Parse(stream, inputInfo.Format); FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); @@ -163,28 +164,36 @@ namespace MediaBrowser.MediaEncoding.Subtitles return (stream, fileInfo); } - private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) + internal async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { - if (fileInfo.Protocol == MediaProtocol.Http) + if (fileInfo.IsExternal && MediaStream.IsTextFormat(fileInfo.Format)) { var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false); var detected = result.Detected; - if (detected is not null) + var stream = fileInfo.Protocol == MediaProtocol.Http + ? await _httpClientFactory.CreateClient(NamedClient.Default) + .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) + .ConfigureAwait(false) + : AsyncFile.OpenRead(fileInfo.Path); + + // Short-circuit when the file is already UTF-8/ASCII. + if (detected is null + || string.Equals(detected.EncodingName, "utf-8", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "ascii", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "us-ascii", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); + return stream; + } - using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) - .ConfigureAwait(false); + _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); - await using (stream.ConfigureAwait(false)) - { - using var reader = new StreamReader(stream, detected.Encoding); - var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + using var reader = new StreamReader(stream, detected.Encoding); + var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - return new MemoryStream(Encoding.UTF8.GetBytes(text)); - } + return new MemoryStream(Encoding.UTF8.GetBytes(text)); } } @@ -445,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath.EscapeProcessArgument(), outputPath.EscapeProcessArgument()); await ExtractSubtitlesForFile( inputPath, @@ -623,7 +632,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - outputPath); + outputPath.EscapeProcessArgument()); } await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); @@ -640,7 +649,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles List<MediaStream> subtitleStreams, CancellationToken cancellationToken) { - var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource); + var inputPath = _mediaEncoder.GetInputPathArgument(mediaSource.Path, mediaSource); var outputPaths = new List<string>(); var args = string.Format( CultureInfo.InvariantCulture, @@ -664,7 +673,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files. var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty; - var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); + var streamIndex = EncodingHelper.GetSubtitleStreamIndexForFfmpeg(mediaSource, subtitleStream); if (streamIndex == -1) { @@ -681,7 +690,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - outputPath); + outputPath.EscapeProcessArgument()); } if (outputPaths.Count > 0) diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 78bb881ec2..dfc057f611 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -612,9 +612,9 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable /// <inheritdoc /> public void OnTranscodeEndRequest(TranscodingJob job) { - job.ActiveRequestCount--; - _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); - if (job.ActiveRequestCount <= 0) + var activeRequestCount = job.DecrementActiveRequestCount(); + _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", activeRequestCount); + if (activeRequestCount <= 0) { PingTimer(job, false); } @@ -697,7 +697,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable return null; } - job.ActiveRequestCount++; + job.IncrementActiveRequestCount(); if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) { job.StopKillTimer(); diff --git a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs index 485a4d2f80..c67f379fde 100644 --- a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs +++ b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Configuration { + /// <summary> + /// The convention used for naming saved images. + /// </summary> public enum ImageSavingConvention { + /// <summary> + /// The legacy naming convention. + /// </summary> Legacy, + + /// <summary> + /// The naming convention compatible with other media servers and metadata managers. + /// </summary> Compatible } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 59f97d8c7c..4799ed5410 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -26,6 +26,8 @@ namespace MediaBrowser.Model.Dlna internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons; internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported; + private const string ManifestContainers = "hls,applehttp,dash"; + private readonly ILogger _logger; private readonly ITranscoderSupport _transcoderSupport; private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"]; @@ -576,11 +578,8 @@ namespace MediaBrowser.Model.Dlna foreach (var profile in subtitleProfiles) { if (profile.Method == SubtitleDeliveryMethod.External - && (string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase) - // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. - || (string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) - && stream.IsVobSubSubtitleStream - && (!stream.IsExternal || stream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))))) + && (IsVobSubMksProfile(profile, stream) + || (!IsVobSubMksDeliveryProfile(profile) && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)))) { return stream.Index; } @@ -721,6 +720,14 @@ namespace MediaBrowser.Model.Dlna isEligibleForDirectPlay = false; } + // A manifest is not a byte stream, so it cannot be handed to the client as one. The variant + // and segment URIs inside it are relative to the origin and do not resolve against the + // Jellyfin url the client would fetch it from. + if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container)) + { + isEligibleForDirectPlay = false; + } + if (bitrateLimitExceeded) { transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit; @@ -1585,18 +1592,20 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!subtitleStream.IsExternal && playMethod == PlayMethod.Transcode && !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec)) + if (!subtitleStream.IsExternal + && playMethod == PlayMethod.Transcode + && !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec) + && !subtitleStream.IsPgsSubtitleStream + && !subtitleStream.IsVobSubSubtitleStream) { continue; } - // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are matched against external .mks delivery profiles. - bool isVobSubMksProfile = string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) - && subtitleStream.IsVobSubSubtitleStream - && (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)); + bool isVobSubMksProfile = IsVobSubMksProfile(profile, subtitleStream); if ((profile.Method == SubtitleDeliveryMethod.External - && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) || + && (isVobSubMksProfile + || (!IsVobSubMksDeliveryProfile(profile) && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)))) || (profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream)) { bool requiresConversion = !isVobSubMksProfile @@ -1628,6 +1637,21 @@ namespace MediaBrowser.Model.Dlna return null; } + private static bool IsVobSubMksDeliveryProfile(SubtitleProfile profile) + { + return MediaStream.IsVobSubFormat(profile.Format) + && !string.IsNullOrWhiteSpace(profile.Container) + && ContainerHelper.ContainsContainer(profile.Container, "mks"); + } + + private static bool IsVobSubMksProfile(SubtitleProfile profile, MediaStream subtitleStream) + { + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. + return IsVobSubMksDeliveryProfile(profile) + && subtitleStream.IsVobSubSubtitleStream + && (!subtitleStream.IsExternal || subtitleStream.Path?.EndsWith(".mks", StringComparison.OrdinalIgnoreCase) == true); + } + private bool IsBitrateLimitExceeded(MediaSourceInfo item, long maxBitrate) { // Don't restrict bitrate if item is remote. diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs index f49b24976a..b5adee173b 100644 --- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs +++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs @@ -141,6 +141,7 @@ public class TranscodingProfile /// Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported. /// </summary> [DefaultValue(false)] + [XmlIgnore] [XmlAttribute("breakOnNonKeyFrames")] [Obsolete("This is always false")] public bool? BreakOnNonKeyFrames { get; set; } diff --git a/MediaBrowser.Model/Drawing/DrawingUtils.cs b/MediaBrowser.Model/Drawing/DrawingUtils.cs index 2040d26bbb..1fdd6a4a49 100644 --- a/MediaBrowser.Model/Drawing/DrawingUtils.cs +++ b/MediaBrowser.Model/Drawing/DrawingUtils.cs @@ -104,6 +104,35 @@ namespace MediaBrowser.Model.Drawing } /// <summary> + /// Scales a size down uniformly until it fits inside a bounding box. + /// Returns the original size if it already fits, so this never upscales. + /// </summary> + /// <param name="size">The size object.</param> + /// <param name="boundingBox">The box the result has to fit inside.</param> + /// <returns>A new size object, or <paramref name="size"/> if it already fits.</returns> + public static ImageDimensions ScaleDownToFit(ImageDimensions size, ImageDimensions boundingBox) + { + if (size.Width <= 0 || size.Height <= 0 || boundingBox.Width <= 0 || boundingBox.Height <= 0) + { + return size; + } + + double widthRatio = size.Width / (double)boundingBox.Width; + double heightRatio = size.Height / (double)boundingBox.Height; + double scaleRatio = Math.Max(widthRatio, heightRatio); + + if (scaleRatio <= 1) + { + return size; + } + + var newWidth = Math.Clamp(Convert.ToInt32(Math.Round(size.Width / scaleRatio)), 1, boundingBox.Width); + var newHeight = Math.Clamp(Convert.ToInt32(Math.Round(size.Height / scaleRatio)), 1, boundingBox.Height); + + return new ImageDimensions(newWidth, newHeight); + } + + /// <summary> /// Gets the new width. /// </summary> /// <param name="currentHeight">Height of the current.</param> diff --git a/MediaBrowser.Model/Dto/IHasServerId.cs b/MediaBrowser.Model/Dto/IHasServerId.cs index c754d276c5..49452d736a 100644 --- a/MediaBrowser.Model/Dto/IHasServerId.cs +++ b/MediaBrowser.Model/Dto/IHasServerId.cs @@ -1,10 +1,15 @@ #nullable disable -#pragma warning disable CS1591 namespace MediaBrowser.Model.Dto { + /// <summary> + /// Interface for DTOs that reference the id of the server they originate from. + /// </summary> public interface IHasServerId { + /// <summary> + /// Gets the server id. + /// </summary> string ServerId { get; } } } diff --git a/MediaBrowser.Model/Dto/MediaSourceType.cs b/MediaBrowser.Model/Dto/MediaSourceType.cs index 42314d5198..ca6649e64b 100644 --- a/MediaBrowser.Model/Dto/MediaSourceType.cs +++ b/MediaBrowser.Model/Dto/MediaSourceType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dto { + /// <summary> + /// The type of a media source. + /// </summary> public enum MediaSourceType { + /// <summary> + /// A default media source. + /// </summary> Default = 0, + + /// <summary> + /// A grouping of media sources. + /// </summary> Grouping = 1, + + /// <summary> + /// A placeholder media source, for example a disc that has to be inserted. + /// </summary> Placeholder = 2 } } diff --git a/MediaBrowser.Model/Dto/RatingType.cs b/MediaBrowser.Model/Dto/RatingType.cs index 033776f9c6..2c2b7b705d 100644 --- a/MediaBrowser.Model/Dto/RatingType.cs +++ b/MediaBrowser.Model/Dto/RatingType.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dto { + /// <summary> + /// The type of a community rating. + /// </summary> public enum RatingType { + /// <summary> + /// The rating is a numeric score. + /// </summary> Score, + + /// <summary> + /// The rating is based on likes. + /// </summary> Likes } } diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index f057714bea..67af843626 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -810,6 +810,11 @@ namespace MediaBrowser.Model.Entities return (VideoRange.Unknown, VideoRangeType.Unknown); } + var isPq = string.Equals(ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase); + var isHlg = string.Equals(ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase); + // Invalid DV only retains HDR when the base layer explicitly signals PQ or HLG. + var baseVideoRange = isPq || isHlg ? VideoRange.HDR : VideoRange.SDR; + var codecTag = CodecTag; var dvProfile = DvProfile; var rpuPresentFlag = RpuPresentFlag == 1; @@ -834,7 +839,7 @@ namespace MediaBrowser.Model.Entities 4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG), 2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR), // Out of Dolby Spec files should be marked as invalid - _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid) + _ => (baseVideoRange, VideoRangeType.DOVIInvalid) }, 7 => (VideoRange.HDR, VideoRangeType.DOVIWithEL), 10 => dvBlCompatId switch @@ -844,11 +849,26 @@ namespace MediaBrowser.Model.Entities 2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR), 4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG), // Out of Dolby Spec files should be marked as invalid - _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid) + _ => (baseVideoRange, VideoRangeType.DOVIInvalid) }, _ => (VideoRange.SDR, VideoRangeType.SDR) }; + var expectedTransfer = dvRangeSet.Item2 switch + { + VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithEL => "smpte2084", + VideoRangeType.DOVIWithHLG => "arib-std-b67", + _ => null + }; + + if (expectedTransfer is not null + && (!string.Equals(ColorSpace, "bt2020nc", StringComparison.OrdinalIgnoreCase) + || !string.Equals(ColorTransfer, expectedTransfer, StringComparison.OrdinalIgnoreCase) + || !string.Equals(ColorPrimaries, "bt2020", StringComparison.OrdinalIgnoreCase))) + { + return (baseVideoRange, VideoRangeType.DOVIInvalid); + } + if (Hdr10PlusPresentFlag == true) { return dvRangeSet.Item2 switch @@ -862,13 +882,11 @@ namespace MediaBrowser.Model.Entities return dvRangeSet; } - var colorTransfer = ColorTransfer; - - if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)) + if (isPq) { return Hdr10PlusPresentFlag == true ? (VideoRange.HDR, VideoRangeType.HDR10Plus) : (VideoRange.HDR, VideoRangeType.HDR10); } - else if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) + else if (isHlg) { return (VideoRange.HDR, VideoRangeType.HLG); } diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 385a86d31c..09eba92d9e 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -1,14 +1,16 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; namespace MediaBrowser.Model.Entities; /// <summary> /// Class ProviderIdsExtensions. /// </summary> -public static class ProviderIdsExtensions +public static partial class ProviderIdsExtensions { /// <summary> /// Case-insensitive dictionary of <see cref="MetadataProvider"/> string representation. @@ -21,6 +23,27 @@ public static class ProviderIdsExtensions StringComparer.OrdinalIgnoreCase); /// <summary> + /// The known id formats, keyed by provider name. + /// </summary> + private static readonly Dictionary<string, Func<string, bool>> _providerIdValidators = + new(StringComparer.OrdinalIgnoreCase) + { + [MetadataProvider.Imdb.ToString()] = value => ImdbIdRegex().IsMatch(value), + [MetadataProvider.Tmdb.ToString()] = IsPositiveNumber, + [MetadataProvider.TmdbCollection.ToString()] = IsPositiveNumber, + [MetadataProvider.AudioDbArtist.ToString()] = IsPositiveNumber, + [MetadataProvider.AudioDbAlbum.ToString()] = IsPositiveNumber, + + // Every MusicBrainz id is an MBID. + [MetadataProvider.MusicBrainzAlbum.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzAlbumArtist.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzArtist.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzReleaseGroup.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzRecording.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzTrack.ToString()] = IsGuid + }; + + /// <summary> /// Checks if this instance has an id for the given provider. /// </summary> /// <param name="instance">The instance.</param> @@ -102,6 +125,26 @@ public static class ProviderIdsExtensions } /// <summary> + /// Checks whether a value can be an id of the given provider. + /// </summary> + /// <param name="name">The provider name.</param> + /// <param name="value">The provider id.</param> + /// <returns><c>true</c> if the value has a plausible format for the provider; otherwise, <c>false</c>.</returns> + /// <remarks> + /// Providers regularly hand out an id belonging to a different service, e.g. an IMDb person id in the + /// TMDb field. Such an id is not just useless, it also makes the owning provider fail for the item. + /// </remarks> + public static bool IsValidProviderId(string? name, string? value) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return !_providerIdValidators.TryGetValue(name, out var isValid) || isValid(value); + } + + /// <summary> /// Sets a provider id. /// </summary> /// <param name="instance">The instance.</param> @@ -121,6 +164,14 @@ public static class ProviderIdsExtensions return false; } + name = name.Trim(); + value = value.Trim(); + + if (!IsValidProviderId(name, value)) + { + return false; + } + // Ensure it exists instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); @@ -153,7 +204,6 @@ public static class ProviderIdsExtensions /// <param name="instance">The instance.</param> /// <param name="name">The name, this should not contain a '=' character.</param> /// <param name="value">The value.</param> - /// <remarks>Due to how deserialization from the database works the name cannot contain '='.</remarks> public static void SetProviderId(this IHasProviderIds instance, string name, string value) { ArgumentNullException.ThrowIfNull(instance); @@ -166,17 +216,27 @@ public static class ProviderIdsExtensions throw new ArgumentException("Provider id name cannot contain '='", nameof(name)); } - // Ensure it exists - instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + instance.TrySetProviderId(name, value); + } - // Match on internal MetadataProvider enum string values before adding arbitrary providers - if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue)) + /// <summary> + /// Replaces all provider ids, dropping the ones that cannot belong to the provider they are filed under. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="providerIds">The provider ids to set.</param> + public static void SetProviderIds(this IHasProviderIds instance, IReadOnlyDictionary<string, string>? providerIds) + { + ArgumentNullException.ThrowIfNull(instance); + + instance.ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + if (providerIds is null) { - instance.ProviderIds[enumValue] = value; + return; } - else + + foreach (var (name, value) in providerIds) { - instance.ProviderIds[name] = value; + instance.TrySetProviderId(name, value); } } @@ -213,4 +273,15 @@ public static class ProviderIdsExtensions instance.ProviderIds?.Remove(provider.ToString()); } + + private static bool IsPositiveNumber(string value) + => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0; + + private static bool IsGuid(string value) + => Guid.TryParse(value, CultureInfo.InvariantCulture, out _); + + // An IMDb id is a type prefix (tt for titles, nm for people, co for companies, ...) followed by + // digits. The prefix is optional because a bare number has always been accepted for a title. + [GeneratedRegex(@"^(tt|nm|co|ev|ch|ni)?[0-9]+$", RegexOptions.IgnoreCase)] + private static partial Regex ImdbIdRegex(); } diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index 7ad240abfb..0fff70c4e0 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -73,6 +73,14 @@ public interface ILocalizationManager CultureDto? FindLanguageInfo(string language); /// <summary> + /// Gets a human-readable display name for the given language code. + /// Truncates at the first semicolon or comma to avoid cluttered ISO-639-2 names. + /// </summary> + /// <param name="language">An ISO language code.</param> + /// <returns>The display name, or null if not found.</returns> + string? GetLanguageDisplayName(string language); + + /// <summary> /// Returns the language in ISO 639-2/T when the input is ISO 639-2/B. /// </summary> /// <param name="isoB">The language in ISO 639-2/B.</param> diff --git a/MediaBrowser.Model/Library/PlayAccess.cs b/MediaBrowser.Model/Library/PlayAccess.cs index a2f263ce54..22daaf7254 100644 --- a/MediaBrowser.Model/Library/PlayAccess.cs +++ b/MediaBrowser.Model/Library/PlayAccess.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Library { + /// <summary> + /// The play access of an item. + /// </summary> public enum PlayAccess { + /// <summary> + /// The item can be played. + /// </summary> Full = 0, + + /// <summary> + /// The item cannot be played. + /// </summary> None = 1 } } diff --git a/MediaBrowser.Model/LiveTv/DayPattern.cs b/MediaBrowser.Model/LiveTv/DayPattern.cs index 17efe39088..dab69e8974 100644 --- a/MediaBrowser.Model/LiveTv/DayPattern.cs +++ b/MediaBrowser.Model/LiveTv/DayPattern.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.LiveTv { + /// <summary> + /// The day pattern of a recurring timer. + /// </summary> public enum DayPattern { + /// <summary> + /// Every day. + /// </summary> Daily, + + /// <summary> + /// Monday through Friday. + /// </summary> Weekdays, + + /// <summary> + /// Saturday and Sunday. + /// </summary> Weekends } } diff --git a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs index 72a0e2d7bf..a3df1dc411 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.LiveTv { + /// <summary> + /// The status of a live TV service. + /// </summary> public enum LiveTvServiceStatus { + /// <summary> + /// The service is available. + /// </summary> Ok = 0, + + /// <summary> + /// The service is unavailable. + /// </summary> Unavailable = 1 } } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 2dddd39ef4..83b44e8b2d 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Model</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs index b7ee5747ab..1988dd8078 100644 --- a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs +++ b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.MediaInfo { + /// <summary> + /// The type of timestamps used in a transport stream. + /// </summary> public enum TransportStreamTimestamp { + /// <summary> + /// The stream contains no timestamps. + /// </summary> None, + + /// <summary> + /// The stream contains zero-value timestamps. + /// </summary> Zero, + + /// <summary> + /// The stream contains valid timestamps. + /// </summary> Valid } } diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs index 787d2ad878..61615b288a 100644 --- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -42,7 +42,7 @@ public class ComicBookInfoProvider : IComicProvider if (path is null) { - _logger.LogError("could not load comic: {Path}", info.Path); + _logger.LogDebug("could not load comic: {Path}", info.Path); return new MetadataResult<Book> { HasMetadata = false }; } @@ -54,9 +54,10 @@ public class ComicBookInfoProvider : IComicProvider var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false); await using (archive.ConfigureAwait(false)) { - if (archive.Comment is null) + // ZipArchive.Comment is an empty string, not null, when the archive has no comment + if (string.IsNullOrWhiteSpace(archive.Comment)) { - _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path); + _logger.LogDebug("missing ComicBookInfo in archive comment: {Path}", info.Path); return new MetadataResult<Book> { HasMetadata = false }; } @@ -71,6 +72,12 @@ public class ComicBookInfoProvider : IComicProvider } } } + catch (JsonException ex) + { + // the archive comment is not reserved for ComicBookInfo, so any other content is not an error + _logger.LogDebug("archive comment is not valid ComicBookInfo metadata: {Path}: {Message}", info.Path, ex.Message); + return new MetadataResult<Book> { HasMetadata = false }; + } catch (Exception ex) { _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path); @@ -204,7 +211,7 @@ public class ComicBookInfoProvider : IComicProvider { try { - return CultureInfo.GetCultureInfo(language).DisplayName; + return CultureInfo.GetCultureInfo(language).TwoLetterISOLanguageName; } catch (CultureNotFoundException) { diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs index 02cc02b7f3..e3d1f544cf 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs @@ -38,7 +38,7 @@ public class ExternalComicInfoProvider : IComicProvider if (comicInfoXml is null) { - _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path); + _logger.LogDebug("No external ComicInfo metadata found for {Path}.", info.Path); return new MetadataResult<Book> { HasMetadata = false }; } @@ -52,7 +52,7 @@ public class ExternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true }; ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); - ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName); return metadataResult; } @@ -67,23 +67,22 @@ public class ExternalComicInfoProvider : IComicProvider private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken) { - var path = GetXmlFilePath(info.Path).FullName; - - if (path is null) + var file = GetXmlFilePath(info.Path); + if (!file.Exists) { return null; } try { - using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true }); + using var reader = XmlReader.Create(file.FullName, new XmlReaderSettings { Async = true }); var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken); return await comicInfoXml.ConfigureAwait(false); } catch (Exception e) { - _logger.LogInformation(e, "Could not load external XML from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path); + _logger.LogWarning(e, "Could not load external ComicInfo XML from {Path}.", file.FullName); return null; } } diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs index 98a6aba7d6..4b14837441 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs @@ -36,7 +36,7 @@ public class InternalComicInfoProvider : IComicProvider if (comicInfoXml is null) { - _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path); + _logger.LogDebug("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path); return new MetadataResult<Book> { HasMetadata = false }; } @@ -50,7 +50,7 @@ public class InternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true }; ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); - ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName); return metadataResult; } diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs index b4e3fd3089..6266413dfc 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Xml; using Jellyfin.Data.Enums; @@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat /// Methods used to pull metadata and other information from Open Packaging Format in XML objects. /// </summary> /// <typeparam name="TCategoryName">The type of category.</typeparam> - public class OpfReader<TCategoryName> + public partial class OpfReader<TCategoryName> { private const string DcNamespace = @"http://purl.org/dc/elements/1.1/"; private const string OpfNamespace = @"http://www.idpf.org/2007/opf"; @@ -42,6 +43,9 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat _namespaceManager.AddNamespace("opf", OpfNamespace); } + [GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")] + private static partial Regex InitialsRegex(); + /// <summary> /// Checks for the existence of a cover image. /// </summary> @@ -229,11 +233,23 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat { foreach (XmlElement creator in resultElement) { - var creatorName = creator.InnerText; var role = creator.GetAttribute("opf:role"); - var person = new PersonInfo { Name = creatorName, Type = GetRole(role) }; - - book.AddPerson(person); + var normalizedCreators = creator.InnerText + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(fullName => + { + if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is [var lastName, var firstName]) + { + fullName = $"{firstName} {lastName}"; + } + + return InitialsRegex().Replace(fullName, ". "); + }); + + foreach (var fullName in normalizedCreators) + { + book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) }); + } } } } diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index 913a104a0d..a19262c3a7 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -32,6 +32,7 @@ public class LyricManager : ILyricManager private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDirectoryService _directoryService; private readonly ILyricProvider[] _lyricProviders; private readonly ILyricParser[] _lyricParsers; @@ -43,6 +44,7 @@ public class LyricManager : ILyricManager /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param> /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param> public LyricManager( @@ -50,6 +52,7 @@ public class LyricManager : ILyricManager IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IMediaSourceManager mediaSourceManager, + IDirectoryService directoryService, IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers) { @@ -57,6 +60,7 @@ public class LyricManager : ILyricManager _fileSystem = fileSystem; _libraryMonitor = libraryMonitor; _mediaSourceManager = mediaSourceManager; + _directoryService = directoryService; _lyricProviders = lyricProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -250,6 +254,8 @@ public class LyricManager : ILyricManager { _libraryMonitor.ReportFileSystemChangeComplete(path, false); } + + _directoryService.Invalidate(path); } return audio.RefreshMetadata(CancellationToken.None); @@ -398,7 +404,7 @@ public class LyricManager : ILyricManager { var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName)); // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path."); - if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal)) + if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath)) { savePaths.Add(mediaFolderPath); } @@ -407,7 +413,7 @@ public class LyricManager : ILyricManager var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName)); // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path."); - if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal)) + if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath)) { savePaths.Add(internalPath); } @@ -446,6 +452,8 @@ public class LyricManager : ILyricManager await stream.CopyToAsync(fs).ConfigureAwait(false); } + _directoryService.Invalidate(savePath); + return; } catch (Exception ex) diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 727f481b65..ce5468c5c4 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -254,6 +254,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + result.Failures++; result.ErrorMessage = ex.Message; _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } @@ -338,6 +339,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + result.Failures++; result.ErrorMessage = ex.Message; _logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name); } diff --git a/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs new file mode 100644 index 0000000000..92a16feaee --- /dev/null +++ b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs @@ -0,0 +1,44 @@ +using System; + +namespace MediaBrowser.Providers.Manager; + +/// <summary> +/// Helpers for comparing the language of fetched metadata with the language that was requested. +/// </summary> +internal static class MetadataLanguageUtils +{ + /// <summary> + /// Gets the language subtag of a language tag, e.g. "es" for "es-ES". + /// </summary> + /// <param name="language">The language tag.</param> + /// <returns>The language subtag, lowercased, or <c>null</c> if none was given.</returns> + public static string? GetLanguageSubtag(string? language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var separator = language.IndexOf('-', StringComparison.Ordinal); + + return (separator == -1 ? language : language[..separator]).ToLowerInvariant(); + } + + /// <summary> + /// Determines whether a provider result can be considered to be in the requested language. + /// </summary> + /// <param name="resultLanguage">The language the provider reported for its result, if any.</param> + /// <param name="preferredLanguage">The language that was requested, if any.</param> + /// <returns><c>true</c> if the result is in the requested language or either language is unknown.</returns> + public static bool MatchesPreferredLanguage(string? resultLanguage, string? preferredLanguage) + { + // A provider that doesn't report a language cannot be judged, assume it honored the request + if (string.IsNullOrEmpty(resultLanguage) || string.IsNullOrEmpty(preferredLanguage)) + { + return true; + } + + // Compare on the language subtag only so that e.g. "es" matches "es-ES" + return string.Equals(GetLanguageSubtag(resultLanguage), GetLanguageSubtag(preferredLanguage), StringComparison.Ordinal); + } +} diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 60eda1628c..05c542337e 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -192,9 +192,13 @@ namespace MediaBrowser.Providers.Manager } } - // Next run remote image providers, but only if local image providers didn't throw an exception - if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly) + if (localImagesFailed) { + hasRefreshedImages = false; + } + else if (refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly) + { + // Next run remote image providers, now that local image providers didn't throw var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList(); if (providers.Count > 0) @@ -209,22 +213,33 @@ namespace MediaBrowser.Providers.Manager } } - if (hasRefreshedMetadata && hasRefreshedImages) + var attemptedFetch = refreshOptions.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly + || refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly; + + var refreshStampNeedsSaving = false; + + if (hasRefreshedMetadata && hasRefreshedImages && attemptedFetch) { item.DateLastRefreshed = DateTime.UtcNow; updateType |= item.OnMetadataChanged(); + + // A full refresh queries every provider whether or not anything looks stale. When they all + // come back empty the stamp is the only thing that changed, and without it nothing records + // that the lookup happened, so the next pass repeats the same fruitless queries forever. + refreshStampNeedsSaving = refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh + || refreshOptions.ImageRefreshMode == MetadataRefreshMode.FullRefresh; } - updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); + updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, refreshStampNeedsSaving, metadataResult, cancellationToken).ConfigureAwait(false); await AfterMetadataRefresh(itemOfType, refreshOptions, cancellationToken).ConfigureAwait(false); return updateType; - async Task<ItemUpdateType> SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, MetadataResult<TItemType> metadataResult, CancellationToken cancellationToken) + async Task<ItemUpdateType> SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, bool refreshStampNeedsSaving, MetadataResult<TItemType> metadataResult, CancellationToken cancellationToken) { // Save if changes were made, or it's never been saved before - if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh) + if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh || refreshStampNeedsSaving) { if (item.IsFileProtocol) { @@ -260,21 +275,40 @@ namespace MediaBrowser.Providers.Manager switch (lookupInfo) { case EpisodeInfo episodeInfo: - episodeInfo.SeriesProviderIds = result.ProviderIds; + episodeInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds); episodeInfo.ProviderIds.Clear(); break; case SeasonInfo seasonInfo: - seasonInfo.SeriesProviderIds = result.ProviderIds; + seasonInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds); seasonInfo.ProviderIds.Clear(); break; default: - lookupInfo.ProviderIds = result.ProviderIds; + lookupInfo.SetProviderIds(result.ProviderIds); lookupInfo.Name = result.Name; lookupInfo.Year = result.ProductionYear; break; } } + private static Dictionary<string, string> GetValidProviderIds(IReadOnlyDictionary<string, string> providerIds) + { + var validProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + if (providerIds is null) + { + return validProviderIds; + } + + foreach (var (name, value) in providerIds) + { + if (ProviderIdsExtensions.IsValidProviderId(name, value)) + { + validProviderIds[name] = value; + } + } + + return validProviderIds; + } + protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken) { await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); @@ -835,6 +869,7 @@ namespace MediaBrowser.Providers.Manager } } + var hasRemoteMetadata = false; var isLocalLocked = temp.Item.IsLocked; if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly)) { @@ -849,6 +884,7 @@ namespace MediaBrowser.Providers.Manager var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false); + hasRemoteMetadata = remoteResult.UpdateType.HasFlag(ItemUpdateType.MetadataDownload); refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; refreshResult.Failures += remoteResult.Failures; @@ -858,7 +894,12 @@ namespace MediaBrowser.Providers.Manager { if (refreshResult.UpdateType > ItemUpdateType.None) { - if (!options.RemoveOldMetadata) + // Erasing the old values is only safe when a remote provider returned something to + // replace them with. If every one of them failed there is no replacement, and wiping the + // item would turn a provider being temporarily unreachable into permanent data loss. + // A single failure is not enough: Identify asks for the erasure precisely because the + // previous match was wrong, and an unrelated provider throwing must not undo that. + if (!options.RemoveOldMetadata || (refreshResult.Failures > 0 && !hasRemoteMetadata)) { // Add existing metadata to provider result if it does not exist there MergeData(metadata, temp, [], false, false); @@ -900,6 +941,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { + refreshResult.Failures++; refreshResult.ErrorMessage = ex.Message; Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName); } @@ -913,6 +955,10 @@ namespace MediaBrowser.Providers.Manager private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken) { var refreshResult = new RefreshResult(); + var preferredLanguage = id?.MetadataLanguage; + + var overviewIsFallback = false; + var taglineIsFallback = false; if (id is not null) { @@ -932,6 +978,28 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; + if (MetadataLanguageUtils.MatchesPreferredLanguage(result.ResultLanguage, preferredLanguage)) + { + if (overviewIsFallback && !string.IsNullOrEmpty(result.Item.Overview)) + { + temp.Item.Overview = null; + overviewIsFallback = false; + } + + if (taglineIsFallback && !string.IsNullOrEmpty(result.Item.Tagline)) + { + temp.Item.Tagline = null; + taglineIsFallback = false; + } + } + else + { + overviewIsFallback |= string.IsNullOrEmpty(temp.Item.Overview) && !string.IsNullOrEmpty(result.Item.Overview); + taglineIsFallback |= string.IsNullOrEmpty(temp.Item.Tagline) && !string.IsNullOrEmpty(result.Item.Tagline); + } + + LogInvalidProviderIds(result, providerName, logName); + MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); @@ -957,6 +1025,58 @@ namespace MediaBrowser.Providers.Manager return refreshResult; } + /// <summary> + /// Reports the ids a provider returned that cannot belong to the provider they are filed under. + /// </summary> + /// <remarks> + /// The ids are dropped when merging, this names the provider that produced them so the source of a + /// recurring bad id can be found. + /// </remarks> + private void LogInvalidProviderIds(MetadataResult<TItemType> result, string providerName, string logName) + { + if (!Logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + LogInvalidProviderIds(result.Item?.ProviderIds, providerName, logName, null); + + if (result.People is null) + { + return; + } + + foreach (var person in result.People) + { + LogInvalidProviderIds(person.ProviderIds, providerName, logName, person.Name); + } + } + + private void LogInvalidProviderIds(IReadOnlyDictionary<string, string> providerIds, string providerName, string logName, string personName) + { + if (providerIds is null) + { + return; + } + + foreach (var (key, value) in providerIds) + { + if (ProviderIdsExtensions.IsValidProviderId(key, value)) + { + continue; + } + + if (personName is null) + { + Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName); + } + else + { + Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Person} of {Item}", key, value, providerName, personName, logName); + } + } + } + private void MergeNewData(TItemType source, TIdType lookupInfo) { // Copy new provider id's that may have been obtained @@ -964,8 +1084,18 @@ namespace MediaBrowser.Providers.Manager { var key = providerId.Key; - // Don't replace existing Id's. - lookupInfo.ProviderIds.TryAdd(key, providerId.Value); + if (!ProviderIdsExtensions.IsValidProviderId(key, providerId.Value)) + { + continue; + } + + // Don't replace existing Id's, unless the one already there is unusable - handing that + // one to the providers that have yet to run is what makes them fail. + if (!lookupInfo.ProviderIds.TryGetValue(key, out var existingId) + || !ProviderIdsExtensions.IsValidProviderId(key, existingId)) + { + lookupInfo.ProviderIds[key] = providerId.Value; + } } } @@ -1038,6 +1168,11 @@ namespace MediaBrowser.Providers.Manager target.OriginalTitle = source.OriginalTitle; } + if (replaceData || string.IsNullOrEmpty(target.HomePageUrl)) + { + target.HomePageUrl = source.HomePageUrl; + } + if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage)) { target.OriginalLanguage = source.OriginalLanguage; @@ -1099,6 +1234,9 @@ namespace MediaBrowser.Providers.Manager if (!lockedFields.Contains(MetadataField.Cast)) { + RemoveInvalidProviderIds(sourceResult.People); + RemoveInvalidProviderIds(targetResult.People); + if (replaceData || targetResult.People is null || targetResult.People.Count == 0) { targetResult.People = sourceResult.People; @@ -1114,7 +1252,7 @@ namespace MediaBrowser.Providers.Manager target.PremiereDate = source.PremiereDate; } - if (replaceData || !target.ProductionYear.HasValue) + if (replaceData || target.ProductionYear is null) { target.ProductionYear = source.ProductionYear; } @@ -1123,7 +1261,7 @@ namespace MediaBrowser.Providers.Manager { if (replaceData || !target.RunTimeTicks.HasValue) { - if (target is not Audio && target is not Video) + if (target is not Audio && target is not Video && target is not Book) { target.RunTimeTicks = source.RunTimeTicks; } @@ -1170,17 +1308,33 @@ namespace MediaBrowser.Providers.Manager { var key = id.Key; - // Don't replace existing Id's. - if (replaceData) + // An id that cannot belong to the provider it is filed under only breaks that provider on + // the next refresh, so never let one in - not even when replacing all metadata. + if (!ProviderIdsExtensions.IsValidProviderId(key, id.Value)) { - target.ProviderIds[key] = id.Value; + continue; } - else + + // Don't replace existing Id's, unless the stored one is unusable - that one is the bad + // match the refresh is meant to repair. + if (replaceData + || !target.ProviderIds.TryGetValue(key, out var existingId) + || !ProviderIdsExtensions.IsValidProviderId(key, existingId)) { - target.ProviderIds.TryAdd(key, id.Value); + target.ProviderIds[key] = id.Value; } } + // A bad id no provider offered a replacement for still has to go, otherwise the item keeps + // failing the same way on every refresh. + foreach (var key in target.ProviderIds + .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value)) + .Select(id => id.Key) + .ToArray()) + { + target.ProviderIds.Remove(key); + } + if (replaceData || !target.CriticRating.HasValue) { target.CriticRating = source.CriticRating; @@ -1246,6 +1400,32 @@ namespace MediaBrowser.Providers.Manager } } + private static void RemoveInvalidProviderIds(IReadOnlyList<PersonInfo> people) + { + if (people is null) + { + return; + } + + foreach (var person in people) + { + if (person.ProviderIds is null || person.ProviderIds.Count == 0) + { + continue; + } + + var invalidKeys = person.ProviderIds + .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value)) + .Select(id => id.Key) + .ToArray(); + + foreach (var key in invalidKeys) + { + person.ProviderIds.Remove(key); + } + } + } + private static void MergePeople(IReadOnlyList<PersonInfo> source, IReadOnlyList<PersonInfo> target) { var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase); diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 73df6d03d2..8387f24bc9 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -76,7 +76,8 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Cache for ordered metadata providers per library/item type combination. - /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata). + /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata), where + /// LibraryPath is the collection folder path the library options are stored against. /// Value: Array of ordered metadata providers (before per-item filtering). /// </summary> private readonly ConcurrentDictionary<MetadataProviderCacheKey, IMetadataProvider[]> _metadataProviderCache = new(); @@ -136,6 +137,7 @@ namespace MediaBrowser.Providers.Manager _similarItemsManager = similarItemsManager; CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated; + _configurationManager.ConfigurationUpdated += OnConfigurationUpdated; } /// <inheritdoc/> @@ -163,6 +165,8 @@ namespace MediaBrowser.Providers.Manager _externalUrlProviders = externalUrlProviders.OrderBy(i => i.Name).ToArray(); _savers = metadataSavers.ToArray(); + + ClearMetadataProviderCache(); } /// <inheritdoc/> @@ -436,6 +440,14 @@ namespace MediaBrowser.Providers.Manager return false; } + // Extras have no identity of their own in an online database, so remote artwork for them + // is always some other item's. Local and dynamic providers still apply, so an extra can + // keep an embedded thumbnail or an extracted frame. + if (item.ExtraType.HasValue && provider is IRemoteImageProvider) + { + return false; + } + return _baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, provider.Name); } @@ -466,15 +478,15 @@ namespace MediaBrowser.Providers.Manager return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, includeDisabled, false, libraryPath); } - private static string GetLibraryPathForItem(BaseItem item) + private string GetLibraryPathForItem(BaseItem item) { if (item is CollectionFolder collectionFolder) { return collectionFolder.Path ?? string.Empty; } - var topParent = item.GetTopParent(); - return topParent?.Path ?? string.Empty; + return _libraryManager.GetCollectionFolders(item) + .Find(folder => folder is CollectionFolder)?.Path ?? string.Empty; } /// <inheritdoc /> @@ -584,6 +596,14 @@ namespace MediaBrowser.Providers.Manager return true; } + // An extra is a local file belonging to another item and has no identity of its own in an + // online database. Looking it up matches whatever the surrounding folder happens to be + // called and overwrites the extra's name with a different item's title. + if (item.ExtraType.HasValue) + { + return false; + } + // Artists without a folder structure that are derived from metadata have no real path in the library, // so GetLibraryOptions returns null. Allow all providers through rather than blocking them. if (item is MusicArtist && libraryTypeOptions is null) @@ -1125,16 +1145,21 @@ namespace MediaBrowser.Providers.Manager return; } - _refreshQueue.Enqueue((itemId, options), priority); - + // PriorityQueue is not thread safe and the processor dequeues concurrently, so every + // touch of the queue takes the lock. lock (_refreshQueueLock) { - if (!_isProcessingRefreshQueue) + _refreshQueue.Enqueue((itemId, options), priority); + + if (_isProcessingRefreshQueue) { - _isProcessingRefreshQueue = true; - Task.Run(StartProcessingRefreshQueue); + return; } + + _isProcessingRefreshQueue = true; } + + Task.Run(StartProcessingRefreshQueue); } private async Task StartProcessingRefreshQueue() @@ -1143,17 +1168,33 @@ namespace MediaBrowser.Providers.Manager if (_disposed) { + lock (_refreshQueueLock) + { + _isProcessingRefreshQueue = false; + } + return; } var cancellationToken = _disposeCancellationTokenSource.Token; libraryManager.ClearIgnoreRuleCache(); - while (_refreshQueue.TryDequeue(out var refreshItem, out _)) + + while (true) { - if (_disposed) + (Guid ItemId, MetadataRefreshOptions RefreshOptions) refreshItem; + + // Dequeueing and standing down happen under one lock, otherwise a refresh queued + // just after the queue ran dry would see a processor that has already stopped. + lock (_refreshQueueLock) { - return; + if (_disposed + || cancellationToken.IsCancellationRequested + || !_refreshQueue.TryDequeue(out refreshItem, out _)) + { + _isProcessingRefreshQueue = false; + break; + } } try @@ -1170,19 +1211,21 @@ namespace MediaBrowser.Providers.Manager await task.ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - break; + // Shutting down: the next pass sees the token and stands the processor down. + continue; } catch (Exception ex) { + // Includes a provider that cancelled for its own reasons, such as an HTTP + // timeout, which must not stop the queue draining. _logger.LogError(ex, "Error refreshing item"); } } - lock (_refreshQueueLock) + if (!_disposed) { - _isProcessingRefreshQueue = false; libraryManager.ClearIgnoreRuleCache(); } } @@ -1273,6 +1316,7 @@ namespace MediaBrowser.Providers.Manager if (disposing) { CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated; + _configurationManager.ConfigurationUpdated -= OnConfigurationUpdated; if (!_disposeCancellationTokenSource.IsCancellationRequested) { @@ -1300,6 +1344,11 @@ namespace MediaBrowser.Providers.Manager _logger.LogDebug("Invalidated metadata provider cache for library: {LibraryPath}", e.LibraryPath); } + private void OnConfigurationUpdated(object? sender, EventArgs e) + { + ClearMetadataProviderCache(); + } + internal void ClearMetadataProviderCache() { _metadataProviderCache.Clear(); @@ -1309,7 +1358,7 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Cache key for metadata provider lookups. /// </summary> - /// <param name="LibraryPath">The library path for the collection folder.</param> + /// <param name="LibraryPath">The path of the collection folder providing the library options.</param> /// <param name="ItemTypeName">The item type name.</param> /// <param name="IncludeDisabled">Whether to include disabled providers.</param> /// <param name="ForceEnableInternetMetadata">Whether internet metadata is force-enabled.</param> diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index df51dd8421..7c3e1867ef 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -21,10 +21,12 @@ <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Newtonsoft.Json" /> + <PackageReference Include="PDFtoImage" /> <PackageReference Include="PlaylistsNET" /> <PackageReference Include="SharpCompress" /> <PackageReference Include="z440.atl.core" /> <PackageReference Include="TMDbLib" /> + <PackageReference Include="UTF.Unknown" /> </ItemGroup> <PropertyGroup> diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index b70cba5b3b..81d4b640a3 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -468,6 +468,24 @@ namespace MediaBrowser.Providers.MediaInfo } } + if (audio.AlbumEntity is not null && !audio.AlbumEntity.NormalizationGain.HasValue) + { + TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_ALBUM_GAIN", out var trackAlbumGainTag); + + if (trackAlbumGainTag is not null) + { + if (trackAlbumGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase)) + { + trackAlbumGainTag = trackAlbumGainTag[..^2].Trim(); + } + + if (float.TryParse(trackAlbumGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && float.IsFinite(value)) + { + audio.AlbumEntity.NormalizationGain = value; + } + } + } + if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _)) { if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag) diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index f9d8883dff..1cb8e09414 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -386,7 +386,7 @@ namespace MediaBrowser.Providers.MediaInfo } } - private void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions) + internal void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions) { var replaceData = refreshOptions.ReplaceAllMetadata; @@ -432,17 +432,19 @@ namespace MediaBrowser.Providers.MediaInfo } } - if (data.ProductionYear.HasValue) + // Extras have no release date of their own, they inherit it from the item they belong to. + var useContainerDates = video.ExtraType is null; + if (useContainerDates && data.ProductionYear is not null) { - if (!video.ProductionYear.HasValue || replaceData) + if (video.ProductionYear is null || replaceData) { video.ProductionYear = data.ProductionYear; } } - if (data.PremiereDate.HasValue) + if (useContainerDates && data.PremiereDate is not null) { - if (!video.PremiereDate.HasValue || replaceData) + if (video.PremiereDate is null || replaceData) { video.PremiereDate = data.PremiereDate; } @@ -482,7 +484,7 @@ namespace MediaBrowser.Providers.MediaInfo } // If we don't have a ProductionYear try and get it from PremiereDate - if (video.PremiereDate.HasValue && !video.ProductionYear.HasValue) + if (useContainerDates && video.PremiereDate is not null && video.ProductionYear is null) { video.ProductionYear = video.PremiereDate.Value.ToLocalTime().Year; } diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index 6f9d5f19da..ecb6e5d990 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -231,10 +231,28 @@ namespace MediaBrowser.Providers.MediaInfo return Array.Empty<ExternalPathParserResult>(); } + // VobSub .sub payloads only carry per-track language metadata when read via + // their paired .idx file, so probe the .idx instead and skip the .sub. Pairing + // requires the same directory (ffprobe can't resolve a split pair) and an + // ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive + // filesystems, so a looser match could suppress a .sub with no working .idx). + // An .idx file with no paired .sub cannot be probed at all, so it is left out + // entirely rather than surfaced (which would otherwise fail every probe and, + // since the .idx would keep "existing" from Jellyfin's point of view, prevent + // stale subtitle stream metadata from ever being cleared once the .sub is gone). + HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle + ? GetPairedVobSubKeys(files) + : null; + var externalPathInfos = new List<ExternalPathParserResult>(); ReadOnlySpan<char> prefix = video.FileNameWithoutExtension; foreach (var file in files) { + if (IsSuppressedVobSubFile(file, pairedVobSubKeys)) + { + continue; + } + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan()); if (fileNameWithoutExtension.Length >= prefix.Length && prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase) @@ -305,6 +323,77 @@ namespace MediaBrowser.Providers.MediaInfo } /// <summary> + /// Determines whether a candidate file is part of a VobSub .idx/.sub pair that + /// should be resolved to only its .idx file, or an .idx file with no paired .sub + /// that cannot be probed at all. + /// </summary> + /// <param name="file">The full path to the candidate file.</param> + /// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not applicable.</param> + /// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns> + private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys) + { + if (pairedVobSubKeys is null) + { + return false; + } + + var extension = Path.GetExtension(file.AsSpan()); + if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) + { + // A paired .idx exists; probe it instead of the .sub payload. + return pairedVobSubKeys.Contains(GetVobSubPairingKey(file)); + } + + if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase)) + { + // Without its .sub payload, the .idx cannot be probed for any data. + return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file)); + } + + return false; + } + + /// <summary> + /// Builds the set of directory+basename keys that have both an .idx and a .sub + /// file present, in a single pass over the candidate files. + /// </summary> + /// <param name="files">The candidate files to search.</param> + /// <returns>The set of pairing keys with both an .idx and .sub present.</returns> + private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files) + { + var idxKeys = new HashSet<string>(StringComparer.Ordinal); + var subKeys = new HashSet<string>(StringComparer.Ordinal); + foreach (var file in files) + { + var extension = Path.GetExtension(file.AsSpan()); + if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase)) + { + idxKeys.Add(GetVobSubPairingKey(file)); + } + else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) + { + subKeys.Add(GetVobSubPairingKey(file)); + } + } + + idxKeys.IntersectWith(subKeys); + return idxKeys; + } + + /// <summary> + /// Builds a directory+basename key used to pair a VobSub .idx file with its .sub + /// payload only when both live in the same directory. + /// </summary> + /// <param name="file">The full path to the file.</param> + /// <returns>A key combining the containing directory and file name without extension.</returns> + private static string GetVobSubPairingKey(string file) + { + var directory = Path.GetDirectoryName(file) ?? string.Empty; + var baseName = Path.GetFileNameWithoutExtension(file); + return Path.Combine(directory, baseName); + } + + /// <summary> /// Returns the media info of the given file. /// </summary> /// <param name="path">The path to the file.</param> diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 789df8f061..5d78cdd0be 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -24,6 +24,8 @@ using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using PDFtoImage; +using SharpCompress.Archives; namespace MediaBrowser.Providers.MediaInfo { @@ -37,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo ICustomMetadataProvider<Video>, ICustomMetadataProvider<Audio>, ICustomMetadataProvider<AudioBook>, + ICustomMetadataProvider<Book>, IHasOrder, IForcedProvider, IPreRefreshProvider, @@ -139,6 +142,12 @@ namespace MediaBrowser.Providers.MediaInfo } } + if (IsMissingMediaInfo(item)) + { + _logger.LogDebug("Refreshing {ItemPath} because it has no media information.", item.Path); + return true; + } + if (video is not null && item.SupportsLocalMetadata && !video.IsPlaceHolder) @@ -172,6 +181,25 @@ namespace MediaBrowser.Providers.MediaInfo return false; } + private static bool IsMissingMediaInfo(BaseItem item) + { + if (item.RunTimeTicks.HasValue + || item.TotalBitrate.HasValue + || item.IsVirtualItem + || item.IsShortcut + || !item.IsFileProtocol) + { + return false; + } + + return item switch + { + Video video => !video.IsPlaceHolder && video.IsCompleteMedia, + Audio => true, + _ => false + }; + } + /// <inheritdoc /> public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken) { @@ -214,6 +242,57 @@ namespace MediaBrowser.Providers.MediaInfo return FetchAudioInfo(item, options, cancellationToken); } + /// <inheritdoc /> + public Task<ItemUpdateType> FetchAsync(Book item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + if (item.IsVirtualItem || !item.IsFileProtocol) + { + return _cachedTask; + } + + long pageCount; + switch (Path.GetExtension(item.Path).ToLowerInvariant()) + { + case ".cb7": + case ".cbr": + case ".cbt": + case ".cbz": + using (var stream = File.OpenRead(item.Path)) + using (var archive = ArchiveFactory.OpenArchive(stream)) + { + pageCount = archive.Entries.Count(e => !e.IsDirectory); + } + + break; + +#pragma warning disable CA1416 + case ".pdf": + using (var stream = File.OpenRead(item.Path)) + { + pageCount = Conversion.GetPageCount(stream); + } + + break; +#pragma warning restore CA1416 + + case ".epub": + // TODO process CFI and store as a string when multiple progress types are supported + // current progress value is percentage stored as a proportion of one second worth of ticks + item.RunTimeTicks = TimeSpan.TicksPerSecond; + + return Task.FromResult(ItemUpdateType.MetadataImport); + + default: + return _cachedTask; + } + + // TODO use page count without modification when multiple progress types are supported + // book players report page count and the web client multiplies that value by 10000 to convert the expected milliseconds into ticks + item.RunTimeTicks = pageCount * 10000; + + return Task.FromResult(ItemUpdateType.MetadataImport); + } + /// <summary> /// Fetches video information for an item. /// </summary> diff --git a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs index d3fce37c71..2923dd3290 100644 --- a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs +++ b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs @@ -23,11 +23,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseGroupId(this AlbumInfo info) { - var id = info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup); + var id = MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -36,11 +36,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseId(this AlbumInfo info) { - var id = info.GetProviderId(MetadataProvider.MusicBrainzAlbum); + var id = MusicBrainzId(MetadataProvider.MusicBrainzAlbum, info.GetProviderId(MetadataProvider.MusicBrainzAlbum)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbum)) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbum, i.GetProviderId(MetadataProvider.MusicBrainzAlbum))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -50,15 +50,17 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this AlbumInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id); + id = MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, id); if (string.IsNullOrEmpty(id)) { info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id); + id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id); } if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -68,14 +70,21 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this ArtistInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id); + id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } return id; } + + /// <summary> + /// Returns the id if it can be an id of the given provider, otherwise <c>null</c>. + /// </summary> + private static string? MusicBrainzId(MetadataProvider provider, string? id) + => ProviderIdsExtensions.IsValidProviderId(provider.ToString(), id) ? id : null; } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 924fde4808..4952d03e8a 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -15,6 +16,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using PlaylistsNET.Content; +using UtfUnknown; namespace MediaBrowser.Providers.Playlists; @@ -26,6 +28,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, IForcedProvider, IHasItemChangeMonitor { + /// <summary> + /// Minimum confidence required before a detected encoding is preferred over UTF-8. + /// </summary> + private const float MinimumEncodingConfidence = 0.5f; + private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; private readonly ILogger<PlaylistItemsProvider> _logger; @@ -136,23 +143,38 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new PlsContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } - private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) + internal IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new M3uContent(); - var playlist = content.GetFromStream(stream); + var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath)); return playlist.PlaylistEntries .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) .Where(i => i is not null); } + private Encoding DetectEncoding(Stream stream, string playlistPath) + { + var detected = CharsetDetector.DetectFromStream(stream).Detected; + stream.Seek(0, SeekOrigin.Begin); + + if (detected?.Encoding is null || detected.Confidence < MinimumEncodingConfidence) + { + _logger.LogDebug("Could not detect the encoding of playlist {Path}, assuming UTF-8", playlistPath); + return Encoding.UTF8; + } + + _logger.LogDebug("Detected encoding {Encoding} for playlist {Path}", detected.Encoding.WebName, playlistPath); + return detected.Encoding; + } + private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots) { var content = new ZplContent(); @@ -191,7 +213,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, { item = null; string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath); - if (!File.Exists(pathToCheck)) + if (!File.Exists(pathToCheck) && !TryNormalizePath(ref pathToCheck)) { return false; } @@ -208,6 +230,36 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, return false; } + private static bool TryNormalizePath(ref string path) + { + foreach (var form in new[] { NormalizationForm.FormC, NormalizationForm.FormD }) + { + string normalized; + try + { + if (path.IsNormalized(form)) + { + continue; + } + + normalized = path.Normalize(form); + } + catch (ArgumentException) + { + // The path is not valid Unicode, there is nothing to normalize. + return false; + } + + if (File.Exists(normalized)) + { + path = normalized; + return true; + } + } + + return false; + } + /// <inheritdoc /> public bool HasChanged(BaseItem item, IDirectoryService directoryService) { diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index 0acd44afbe..1903adfbdd 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -21,6 +21,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -77,7 +78,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { result.Item = new MusicAlbum(); result.HasMetadata = true; - ProcessResult(result.Item, obj.album[0], info.MetadataLanguage); + ProcessResult(result, obj.album[0], info.MetadataLanguage); } } } @@ -85,8 +86,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return result; } - private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage) + private void ProcessResult(MetadataResult<MusicAlbum> metadataResult, Album result, string preferredLanguage) { + var item = metadataResult.Item; + if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum)) { item.Album = result.strAlbum; @@ -113,43 +116,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb item.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID); item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, result.strMusicBrainzID); - string overview = null; - - if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionDE; - } - else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionFR; - } - else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionNL; - } - else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionRU; - } - else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionIT; - } - else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionPT; - } + var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage); + var overview = GetDescription(result, language); if (string.IsNullOrWhiteSpace(overview)) { overview = string.IsNullOrWhiteSpace(result.strDescriptionEN) ? result.strDescription : result.strDescriptionEN; + + // The description is not in the requested language, mark it as English so it does not + // block a provider further down the list that can serve the requested language + metadataResult.ResultLanguage = "en"; + } + else + { + metadataResult.ResultLanguage = language; } item.Overview = (overview ?? string.Empty).StripHtml(); } + private static string GetDescription(Album result, string language) + => language switch + { + "de" => result.strDescriptionDE, + "en" => result.strDescriptionEN, + "es" => result.strDescriptionES, + "fr" => result.strDescriptionFR, + "he" => result.strDescriptionIL, + "hu" => result.strDescriptionHU, + "it" => result.strDescriptionIT, + "ja" => result.strDescriptionJP, + "nl" => result.strDescriptionNL, + "no" or "nb" or "nn" => result.strDescriptionNO, + "pl" => result.strDescriptionPL, + "pt" => result.strDescriptionPT, + "ru" => result.strDescriptionRU, + "sv" => result.strDescriptionSE, + "zh" => result.strDescriptionCN, + _ => null + }; + internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) { var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId); diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index 88730f34d2..28cfc8f9a4 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -3,32 +3,24 @@ #pragma warning disable CS1591 using System.Collections.Generic; -using System.IO; using System.Net.Http; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.AudioDb { public class AudioDbArtistImageProvider : IRemoteImageProvider, IHasOrder { - private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory) + public AudioDbArtistImageProvider(IHttpClientFactory httpClientFactory) { - _config = config; _httpClientFactory = httpClientFactory; } @@ -54,22 +46,14 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { - if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id)) - { - await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); - - var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); + item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var musicBrainzId); + item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var audioDbId); - FileStream jsonStream = AsyncFile.OpenRead(path); - await using (jsonStream.ConfigureAwait(false)) - { - var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var artist = await AudioDbArtistProvider.Current.GetArtist(musicBrainzId, audioDbId, cancellationToken).ConfigureAwait(false); - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) - { - return GetImages(obj.artists[0]); - } - } + if (artist is not null) + { + return GetImages(artist); } return []; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index d8cb6b4b24..2d9fe4448f 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -4,9 +4,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -20,6 +22,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -52,87 +55,225 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public int Order => 1; /// <inheritdoc /> - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult(Enumerable.Empty<RemoteSearchResult>()); - - /// <inheritdoc /> - public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) { - var result = new MetadataResult<MusicArtist>(); - var id = info.GetMusicBrainzArtistId(); + // Prefer a known TheAudioDB artist id. + var audioDbId = searchInfo.GetProviderId(MetadataProvider.AudioDbArtist); + if (!string.IsNullOrWhiteSpace(audioDbId)) + { + var artists = await FetchArtists(BaseUrl + "/artist.php?i=" + audioDbId, cancellationToken).ConfigureAwait(false); + return artists.Select(ToRemoteSearchResult); + } - if (!string.IsNullOrWhiteSpace(id)) + // Fall back to the MusicBrainz artist id, reusing the on-disk cache also used by GetMetadata. + var musicBrainzId = searchInfo.GetMusicBrainzArtistId(); + if (!string.IsNullOrWhiteSpace(musicBrainzId)) { - await EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); + await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false); - var path = GetArtistInfoPath(_config.ApplicationPaths, id); + var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); FileStream jsonStream = AsyncFile.OpenRead(path); await using (jsonStream.ConfigureAwait(false)) { var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + if (obj is not null && obj.artists is not null) { - result.Item = new MusicArtist(); - result.HasMetadata = true; - ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage); + return obj.artists.Select(ToRemoteSearchResult); } } + + return []; + } + + // Finally, search by name. + if (!string.IsNullOrWhiteSpace(searchInfo.Name)) + { + var artists = await FetchArtists(BaseUrl + "/search.php?s=" + Uri.EscapeDataString(searchInfo.Name), cancellationToken).ConfigureAwait(false); + return artists.Select(ToRemoteSearchResult); + } + + return []; + } + + private async Task<List<Artist>> FetchArtists(string url, CancellationToken cancellationToken) + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var obj = await response.Content.ReadFromJsonAsync<RootObject>(_jsonOptions, cancellationToken).ConfigureAwait(false); + + return obj?.artists ?? []; + } + + private RemoteSearchResult ToRemoteSearchResult(Artist artist) + { + var result = new RemoteSearchResult + { + Name = artist.strArtist, + ImageUrl = artist.strArtistThumb, + SearchProviderName = Name, + Overview = (artist.strBiographyEN ?? string.Empty).StripHtml() + }; + + if (!string.IsNullOrEmpty(artist.idArtist)) + { + result.SetProviderId(MetadataProvider.AudioDbArtist, artist.idArtist); + } + + if (!string.IsNullOrEmpty(artist.strMusicBrainzID)) + { + result.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.strMusicBrainzID); + } + + if (int.TryParse(artist.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear)) + { + result.ProductionYear = formedYear; } return result; } - private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage) + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) { - // item.HomePageUrl = result.strWebsite; + var result = new MetadataResult<MusicArtist>(); - if (!string.IsNullOrEmpty(result.strGenre)) + var artist = await GetArtist( + info.GetMusicBrainzArtistId(), + info.GetProviderId(MetadataProvider.AudioDbArtist), + cancellationToken).ConfigureAwait(false); + + if (artist is not null) { - item.Genres = new[] { result.strGenre }; + result.Item = new MusicArtist(); + result.HasMetadata = true; + ProcessResult(result, artist, info.MetadataLanguage); } - item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist); - item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID); + return result; + } + + /// <summary> + /// Resolves the cached AudioDB artist, preferring the MusicBrainz id and falling back to the AudioDB id. + /// </summary> + /// <param name="musicBrainzId">The MusicBrainz artist id, if known.</param> + /// <param name="audioDbId">The TheAudioDB artist id, if known.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The matching artist, or <c>null</c> if none could be resolved.</returns> + internal async Task<Artist> GetArtist(string musicBrainzId, string audioDbId, CancellationToken cancellationToken) + { + string path; + if (!string.IsNullOrWhiteSpace(musicBrainzId)) + { + await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false); + path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); + } + else if (!string.IsNullOrWhiteSpace(audioDbId)) + { + await EnsureArtistInfoByAudioDbId(audioDbId, cancellationToken).ConfigureAwait(false); + path = GetArtistInfoPath(_config.ApplicationPaths, audioDbId); + } + else + { + return null; + } + + FileStream jsonStream = AsyncFile.OpenRead(path); + await using (jsonStream.ConfigureAwait(false)) + { + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - string overview = null; + if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + { + return obj.artists[0]; + } + } - if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) + return null; + } + + private void ProcessResult(MetadataResult<MusicArtist> metadataResult, Artist result, string preferredLanguage) + { + var item = metadataResult.Item; + + if (!string.IsNullOrWhiteSpace(result.strWebsite)) { - overview = result.strBiographyDE; + item.HomePageUrl = result.strWebsite; } - else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) + + var genres = new List<string>(); + if (!string.IsNullOrWhiteSpace(result.strGenre)) { - overview = result.strBiographyFR; + genres.Add(result.strGenre); } - else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) + + if (!string.IsNullOrWhiteSpace(result.strSubGenre)) { - overview = result.strBiographyNL; + genres.Add(result.strSubGenre); } - else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) + + if (genres.Count > 0) { - overview = result.strBiographyRU; + item.Genres = genres.ToArray(); } - else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) + + if (int.TryParse(result.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear)) { - overview = result.strBiographyIT; + item.ProductionYear = formedYear; } - else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) + + if (!string.IsNullOrWhiteSpace(result.strCountry)) { - overview = result.strBiographyPT; + item.ProductionLocations = new[] { result.strCountry }; } + item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist); + item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID); + + var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage); + var overview = GetBiography(result, language); + if (string.IsNullOrWhiteSpace(overview)) { overview = string.IsNullOrWhiteSpace(result.strBiographyEN) ? result.strBiography : result.strBiographyEN; + + // The biography is not in the requested language, mark it as English so it does not + // block a provider further down the list that can serve the requested language + metadataResult.ResultLanguage = "en"; + } + else + { + metadataResult.ResultLanguage = language; } item.Overview = (overview ?? string.Empty).StripHtml(); } + private static string GetBiography(Artist result, string language) + => language switch + { + "de" => result.strBiographyDE, + "en" => result.strBiographyEN, + "es" => result.strBiographyES, + "fr" => result.strBiographyFR, + "he" => result.strBiographyIL, + "hu" => result.strBiographyHU, + "it" => result.strBiographyIT, + "ja" => result.strBiographyJP, + "nl" => result.strBiographyNL, + "no" or "nb" or "nn" => result.strBiographyNO, + "pl" => result.strBiographyPL, + "pt" => result.strBiographyPT, + "ru" => result.strBiographyRU, + "sv" => result.strBiographySE, + "zh" => result.strBiographyCN, + _ => null + }; + internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken) { var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); @@ -150,13 +291,32 @@ namespace MediaBrowser.Providers.Plugins.AudioDb internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - var url = BaseUrl + "/artist-mb.php?i=" + musicBrainzId; + await DownloadArtistInfo(url, GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId), cancellationToken).ConfigureAwait(false); + } + + internal async Task EnsureArtistInfoByAudioDbId(string audioDbId, CancellationToken cancellationToken) + { + var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, audioDbId); + + var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath); + + if (fileInfo.Exists + && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) + { + return; + } + + var url = BaseUrl + "/artist.php?i=" + audioDbId; + await DownloadArtistInfo(url, xmlPath, cancellationToken).ConfigureAwait(false); + } + + private async Task DownloadArtistInfo(string url, string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); Directory.CreateDirectory(Path.GetDirectoryName(path)); var fileStreamOptions = AsyncFile.WriteOptions; diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html index dec21d1b42..d485fe555a 100644 --- a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html @@ -7,7 +7,6 @@ <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select"> <div data-role="content"> <div class="content-primary"> - <img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" /> <h1>ListenBrainz</h1> <p>Get similar artist recommendations from ListenBrainz Labs.</p> <form class="configForm"> @@ -18,12 +17,12 @@ <div class="selectContainer"> <label class="selectLabel" for="algorithm">Similarity Algorithm</label> <select is="emby-select" id="algorithm" class="emby-select-withcolor"> - <option value="0" selected>~5 years / 1825 days (Recommended)</option> - <option value="1">~5 years / 1800 days</option> - <option value="2">~20 years / 7500 days</option> - <option value="3">~20 years / 7500 days (high contribution)</option> - <option value="4">~25 years / 9000 days</option> - <option value="5">~75 days (recent)</option> + <option value="SessionBased1825Days" selected>~5 years / 1825 days (Recommended)</option> + <option value="SessionBased1800Days">~5 years / 1800 days</option> + <option value="SessionBased7500Days">~20 years / 7500 days</option> + <option value="SessionBased7500DaysHighContribution">~20 years / 7500 days (high contribution)</option> + <option value="SessionBased9000Days">~25 years / 9000 days</option> + <option value="SessionBased75Days">~75 days (recent)</option> </select> <div class="fieldDescription">The algorithm used for artist similarity calculation.</div> </div> @@ -52,13 +51,14 @@ </div> <script type="text/javascript"> var ListenBrainzPluginConfig = { - uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e" + uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e", + defaultAlgorithm: "SessionBased1825Days" }; document.querySelector('.configPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); - document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' }); + ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { var labsServer = document.querySelector('#labsServer'); labsServer.value = config.LabsServer; @@ -67,7 +67,13 @@ cancelable: false })); - document.querySelector('#algorithm').value = config.Algorithm; + // The API serialises the algorithm as its enum name, so an unknown value here + // means a config written by an older build; fall back to the default. + var algorithm = document.querySelector('#algorithm'); + algorithm.value = config.Algorithm; + if (!algorithm.value) { + algorithm.value = ListenBrainzPluginConfig.defaultAlgorithm; + } var rateLimit = document.querySelector('#rateLimit'); rateLimit.value = config.RateLimit; @@ -93,7 +99,7 @@ ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { config.LabsServer = document.querySelector('#labsServer').value; - config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10); + config.Algorithm = document.querySelector('#algorithm').value; config.RateLimit = document.querySelector('#rateLimit').value; config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10); diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 715bdd9da4..7dee5fd31d 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -14,6 +14,7 @@ using MediaBrowser.Providers.Music; using MetaBrainz.MusicBrainz; using MetaBrainz.MusicBrainz.Interfaces.Entities; using MetaBrainz.MusicBrainz.Interfaces.Searches; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Plugins.MusicBrainz; @@ -22,6 +23,17 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// </summary> public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder { + private readonly ILogger<MusicBrainzAlbumProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + public MusicBrainzAlbumProvider(ILogger<MusicBrainzAlbumProvider> logger) + { + _logger = logger; + } + /// <inheritdoc /> public string Name => "MusicBrainz"; @@ -32,21 +44,26 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) { var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; - var releaseId = searchInfo.GetReleaseId(); - var releaseGroupId = searchInfo.GetReleaseGroupId(); + var releaseId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchInfo.GetReleaseId(), "release", _logger); + var releaseGroupId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchInfo.GetReleaseGroupId(), "release group", _logger); - if (!string.IsNullOrEmpty(releaseId)) + if (releaseId is not null) { - var releaseResult = await query.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); - return GetReleaseResult(releaseResult).SingleItemAsEnumerable(); + var releaseResult = await query.LookupReleaseOrNullAsync(releaseId.Value, Include.Artists | Include.ReleaseGroups, _logger, cancellationToken).ConfigureAwait(false); + if (releaseResult is not null) + { + return GetReleaseResult(releaseResult).SingleItemAsEnumerable(); + } } - if (!string.IsNullOrEmpty(releaseGroupId)) + if (releaseGroupId is not null) { - var releaseGroupResult = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false); - - // 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 releaseGroupResult = await query.LookupReleaseGroupOrNullAsync(releaseGroupId.Value, Include.Releases, _logger, cancellationToken).ConfigureAwait(false); + if (releaseGroupResult is not null) + { + // 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(); @@ -102,8 +119,11 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu foreach (var result in releaseSearchResults) { // Fetch full release info, otherwise artists are missing - var fullResult = await query.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); - yield return GetReleaseResult(fullResult); + var fullResult = await query.LookupReleaseOrNullAsync(result.Id, Include.Artists | Include.ReleaseGroups, _logger, cancellationToken).ConfigureAwait(false); + if (fullResult is not null) + { + yield return GetReleaseResult(fullResult); + } } } @@ -155,10 +175,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu /// <inheritdoc /> public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) { - // TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it. var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; - var releaseId = info.GetReleaseId(); - var releaseGroupId = info.GetReleaseGroupId(); + var releaseId = MusicBrainzQueryExtensions.ParseMusicBrainzId(info.GetReleaseId(), "release", _logger); + var releaseGroupId = MusicBrainzQueryExtensions.ParseMusicBrainzId(info.GetReleaseGroupId(), "release group", _logger); var result = new MetadataResult<MusicAlbum> { @@ -166,20 +185,15 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu }; // If there is a release group, but no release ID, try to match the release - if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) + if (releaseId is null && releaseGroupId is not null) { // TODO: Actually try to match the release. Simply taking the first result is stupid. - var releaseGroup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); - var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null; - if (release is not null) - { - releaseId = release.Id.ToString(); - result.HasMetadata = true; - } + var releaseGroupLookup = await query.LookupReleaseGroupOrNullAsync(releaseGroupId.Value, Include.None, _logger, cancellationToken).ConfigureAwait(false); + releaseId = releaseGroupLookup?.Releases?.Count > 0 ? releaseGroupLookup.Releases[0].Id : null; } // If there is no release ID, lookup a release with the info we have - if (string.IsNullOrWhiteSpace(releaseId)) + if (releaseId is null) { var artistMusicBrainzId = info.GetMusicBrainzArtistId(); IRelease? releaseResult = null; @@ -199,49 +213,118 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (releaseResult is not null) { - releaseId = releaseResult.Id.ToString(); + releaseId = releaseResult.Id; if (releaseResult.ReleaseGroup?.Id is not null) { - releaseGroupId = releaseResult.ReleaseGroup.Id.ToString(); + releaseGroupId = releaseResult.ReleaseGroup.Id; } + } + } + + if (releaseId is null && releaseGroupId is null) + { + return result; + } + + // Fetch the full release (and its release group) so we can populate everything MusicBrainz returns. + IRelease? release = null; + if (releaseId is not null) + { + release = await query.LookupReleaseOrNullAsync( + releaseId.Value, + Include.Artists | Include.ReleaseGroups | Include.Labels | Include.Genres | Include.Tags, + _logger, + cancellationToken).ConfigureAwait(false); - result.HasMetadata = true; - result.Item.ProductionYear = releaseResult.Date?.Year; - result.Item.Overview = releaseResult.Annotation; + if (releaseGroupId is null && release?.ReleaseGroup?.Id is not null) + { + releaseGroupId = release.ReleaseGroup.Id; } } - // If we have a release ID but not a release group ID, lookup the release group - if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) + IReleaseGroup? releaseGroup = null; + if (releaseGroupId is not null) { - var release = await query.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); - releaseGroupId = release.ReleaseGroup?.Id.ToString(); - result.HasMetadata = true; + releaseGroup = await query.LookupReleaseGroupOrNullAsync( + releaseGroupId.Value, + Include.Artists | Include.Genres | Include.Tags, + _logger, + cancellationToken).ConfigureAwait(false); } - // If we have a release ID and a release group ID - if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) + if (release is null && releaseGroup is null) { - result.HasMetadata = true; + return result; } - if (result.HasMetadata) + result.HasMetadata = true; + + if (releaseId is not null) { - if (!string.IsNullOrEmpty(releaseId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); - } + result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId.Value.ToString()); + } - if (!string.IsNullOrEmpty(releaseGroupId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); - } + if (releaseGroupId is not null) + { + result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId.Value.ToString()); } + Populate(result.Item, release, releaseGroup); + return result; } + private static void Populate(MusicAlbum item, IRelease? release, IReleaseGroup? releaseGroup) + { + // Prefer the release group (album-level) data, falling back to the specific release. + // The release group's first release date is the original album date. + var date = releaseGroup?.FirstReleaseDate ?? release?.Date; + if (date is not null) + { + item.PremiereDate = date.NearestDate; + item.ProductionYear = date.Year; + } + + var artistCredit = release?.ArtistCredit ?? releaseGroup?.ArtistCredit; + if (artistCredit is not null && artistCredit.Count > 0) + { + item.AlbumArtists = artistCredit + .Select(credit => credit.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + var genres = releaseGroup?.Genres ?? release?.Genres; + if (genres is not null && genres.Count > 0) + { + item.Genres = genres + .OrderByDescending(genre => genre.VoteCount) + .Select(genre => genre.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + var tags = releaseGroup?.Tags ?? release?.Tags; + if (tags is not null && tags.Count > 0) + { + item.Tags = tags + .OrderByDescending(tag => tag.VoteCount) + .Select(tag => tag.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + if (release?.LabelInfo is not null && release.LabelInfo.Count > 0) + { + item.Studios = release.LabelInfo + .Where(labelInfo => !string.IsNullOrWhiteSpace(labelInfo.Label?.Name)) + .Select(labelInfo => labelInfo.Label!.Name!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 0fe4e6bb16..c3d13ed42c 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -13,6 +13,7 @@ using MediaBrowser.Providers.Music; using MetaBrainz.MusicBrainz; using MetaBrainz.MusicBrainz.Interfaces.Entities; using MetaBrainz.MusicBrainz.Interfaces.Searches; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Plugins.MusicBrainz; @@ -21,6 +22,17 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// </summary> public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IHasOrder { + private readonly ILogger<MusicBrainzArtistProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + public MusicBrainzArtistProvider(ILogger<MusicBrainzArtistProvider> logger) + { + _logger = logger; + } + /// <inheritdoc /> public string Name => "MusicBrainz"; @@ -32,12 +44,20 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) { var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; - var artistId = searchInfo.GetMusicBrainzArtistId(); + var artistId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchInfo.GetMusicBrainzArtistId(), "artist", _logger); + + if (artistId is not null) + { + var artistResult = await query.LookupArtistOrNullAsync(artistId.Value, Include.Aliases, _logger, cancellationToken).ConfigureAwait(false); + if (artistResult is not null) + { + return GetResultFromResponse(artistResult).SingleItemAsEnumerable(); + } + } - if (!string.IsNullOrWhiteSpace(artistId)) + if (string.IsNullOrWhiteSpace(searchInfo.Name)) { - var artistResult = await query.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false); - return GetResultFromResponse(artistResult).SingleItemAsEnumerable(); + return []; } var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) @@ -58,7 +78,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar } } - return Enumerable.Empty<RemoteSearchResult>(); + return []; } private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults) @@ -94,30 +114,69 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar { var result = new MetadataResult<MusicArtist> { Item = new MusicArtist() }; - var musicBrainzId = info.GetMusicBrainzArtistId(); + var musicBrainzId = MusicBrainzQueryExtensions.ParseMusicBrainzId(info.GetMusicBrainzArtistId(), "artist", _logger); - if (string.IsNullOrWhiteSpace(musicBrainzId)) + // If we don't have an id yet, resolve one by name so we can look the artist up. + if (musicBrainzId is null) { var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); + musicBrainzId = MusicBrainzQueryExtensions.ParseMusicBrainzId(searchResults.FirstOrDefault()?.GetProviderId(MetadataProvider.MusicBrainzArtist), "artist", _logger); + } - var singleResult = searchResults.FirstOrDefault(); + if (musicBrainzId is null) + { + return result; + } - if (singleResult is not null) - { - musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist); - result.Item.Overview = singleResult.Overview; + var query = Plugin.Instance!.MusicBrainzQuery; + var artist = await query.LookupArtistOrNullAsync(musicBrainzId.Value, Include.Genres | Include.Tags, _logger, cancellationToken).ConfigureAwait(false); - if (Plugin.Instance!.Configuration.ReplaceArtistName) - { - result.Item.Name = singleResult.Name; - } - } + if (artist is null) + { + return result; + } + + result.HasMetadata = true; + result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString()); + + if (Plugin.Instance!.Configuration.ReplaceArtistName && !string.IsNullOrWhiteSpace(artist.Name)) + { + result.Item.Name = artist.Name; + } + + if (artist.LifeSpan?.Begin is not null) + { + result.Item.PremiereDate = artist.LifeSpan.Begin.NearestDate; + result.Item.ProductionYear = artist.LifeSpan.Begin.Year; + } + + if (artist.LifeSpan?.End is not null) + { + result.Item.EndDate = artist.LifeSpan.End.NearestDate; + } + + var location = string.IsNullOrWhiteSpace(artist.Area?.Name) ? artist.Country : artist.Area!.Name; + if (!string.IsNullOrWhiteSpace(location)) + { + result.Item.ProductionLocations = [location]; + } + + if (artist.Genres is not null && artist.Genres.Count > 0) + { + result.Item.Genres = artist.Genres + .OrderByDescending(genre => genre.VoteCount) + .Select(genre => genre.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); } - if (!string.IsNullOrWhiteSpace(musicBrainzId)) + if (artist.Tags is not null && artist.Tags.Count > 0) { - result.HasMetadata = true; - result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId); + result.Item.Tags = artist.Tags + .OrderByDescending(tag => tag.VoteCount) + .Select(tag => tag.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); } return result; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs new file mode 100644 index 0000000000..f3df41e942 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzQueryExtensions.cs @@ -0,0 +1,112 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using MetaBrainz.Common; +using MetaBrainz.MusicBrainz; +using MetaBrainz.MusicBrainz.Interfaces.Entities; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// Helpers for talking to MusicBrainz with identifiers that are not guaranteed to be valid. +/// </summary> +internal static class MusicBrainzQueryExtensions +{ + /// <summary> + /// Parses a MusicBrainz identifier, which may come from user-supplied tags or NFO files and is therefore not + /// guaranteed to be a valid GUID. + /// </summary> + /// <param name="id">The identifier to parse.</param> + /// <param name="entityType">The type of entity the identifier refers to, used for logging.</param> + /// <param name="logger">The logger.</param> + /// <returns>The parsed identifier, or <see langword="null"/> if it is missing or malformed.</returns> + public static Guid? ParseMusicBrainzId(string? id, string entityType, ILogger logger) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + if (!Guid.TryParse(id, out var parsedId)) + { + logger.LogDebug("Ignoring malformed MusicBrainz {EntityType} id {Id}", entityType, id); + return null; + } + + return parsedId; + } + + /// <summary> + /// Looks up a release, treating an unknown identifier as missing data rather than an error. + /// </summary> + /// <param name="query">The MusicBrainz query client.</param> + /// <param name="releaseId">The release identifier.</param> + /// <param name="include">The additional data to include in the lookup.</param> + /// <param name="logger">The logger.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The release, or <see langword="null"/> if MusicBrainz does not have it.</returns> + public static Task<IRelease?> LookupReleaseOrNullAsync(this Query query, Guid releaseId, Include include, ILogger logger, CancellationToken cancellationToken) + => NotFoundAsNullAsync( + () => query.LookupReleaseAsync(releaseId, include, cancellationToken), + "release", + releaseId, + logger); + + /// <summary> + /// Looks up a release group, treating an unknown identifier as missing data rather than an error. + /// </summary> + /// <param name="query">The MusicBrainz query client.</param> + /// <param name="releaseGroupId">The release group identifier.</param> + /// <param name="include">The additional data to include in the lookup.</param> + /// <param name="logger">The logger.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The release group, or <see langword="null"/> if MusicBrainz does not have it.</returns> + public static Task<IReleaseGroup?> LookupReleaseGroupOrNullAsync(this Query query, Guid releaseGroupId, Include include, ILogger logger, CancellationToken cancellationToken) + => NotFoundAsNullAsync( + () => query.LookupReleaseGroupAsync(releaseGroupId, include, null, cancellationToken), + "release group", + releaseGroupId, + logger); + + /// <summary> + /// Looks up an artist, treating an unknown identifier as missing data rather than an error. + /// </summary> + /// <param name="query">The MusicBrainz query client.</param> + /// <param name="artistId">The artist identifier.</param> + /// <param name="include">The additional data to include in the lookup.</param> + /// <param name="logger">The logger.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The artist, or <see langword="null"/> if MusicBrainz does not have it.</returns> + public static Task<IArtist?> LookupArtistOrNullAsync(this Query query, Guid artistId, Include include, ILogger logger, CancellationToken cancellationToken) + => NotFoundAsNullAsync( + () => query.LookupArtistAsync(artistId, include, null, null, cancellationToken), + "artist", + artistId, + logger); + + /// <summary> + /// Runs a lookup, mapping a "not found" response to <see langword="null"/>. Identifiers stored on a library item + /// can refer to entities that no longer exist in MusicBrainz, which is not an error worth failing a refresh over. + /// </summary> + /// <typeparam name="T">The type of entity being looked up.</typeparam> + /// <param name="lookup">The lookup to run.</param> + /// <param name="entityType">The type of entity being looked up, used for logging.</param> + /// <param name="id">The identifier being looked up, used for logging.</param> + /// <param name="logger">The logger.</param> + /// <returns>The entity, or <see langword="null"/> if MusicBrainz does not have it.</returns> + private static async Task<T?> NotFoundAsNullAsync<T>(Func<Task<T>> lookup, string entityType, Guid id, ILogger logger) + where T : class + { + try + { + return await lookup().ConfigureAwait(false); + } + catch (HttpError ex) when (ex.Status == HttpStatusCode.NotFound) + { + logger.LogDebug("MusicBrainz has no {EntityType} with id {Id}", entityType, id); + return null; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index ccff31ebaa..437a997c11 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -44,7 +44,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = new MetadataResult<Episode> { Item = new Episode(), - QueriedById = true + QueriedById = true, + // OMDb is not localized, everything it returns is English + ResultLanguage = "en" }; // Allowing this will dramatically increase scan times diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index e84f1359b7..7b245ea5a7 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -218,7 +218,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = new MetadataResult<T> { Item = new T(), - QueriedById = true + QueriedById = true, + // OMDb is not localized, everything it returns is English + ResultLanguage = "en" }; var imdbId = info.GetProviderId(MetadataProvider.Imdb); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index f562d64ddd..d51d913caa 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -27,6 +27,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb /// <summary>Provider for OMDB service.</summary> public class OmdbProvider { + /// <summary>Generational suffixes that OMDb separates from the name with a comma.</summary> + private static readonly string[] NameSuffixes = ["Jr", "Jnr", "Sr", "Snr", "II", "III", "IV", "V"]; + private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; @@ -420,42 +423,96 @@ namespace MediaBrowser.Providers.Plugins.Omdb return; } - if (!string.IsNullOrWhiteSpace(result.Director)) - { - var person = new PersonInfo - { - Name = result.Director.Trim(), - Type = PersonKind.Director - }; + AddPeople(itemResult, result.Director, PersonKind.Director); + AddPeople(itemResult, result.Writer, PersonKind.Writer); + AddPeople(itemResult, result.Actors, PersonKind.Actor); + } - itemResult.AddPerson(person); + /// <summary>Adds the people from a comma separated OMDb credit list.</summary> + /// <typeparam name="T">The item type.</typeparam> + /// <param name="itemResult">The metadata result to add the people to.</param> + /// <param name="credits">The comma separated OMDb credit list.</param> + /// <param name="type">The kind of person each credit describes.</param> + internal static void AddPeople<T>(MetadataResult<T> itemResult, string credits, PersonKind type) + where T : BaseItem + { + if (string.IsNullOrWhiteSpace(credits)) + { + return; } - if (!string.IsNullOrWhiteSpace(result.Writer)) + var names = new List<string>(); + + foreach (var credit in SplitCredits(credits)) { - var person = new PersonInfo + // OMDb annotates the credited role in parentheses, e.g. "Mari Okada (screenplay)". The same + // person can be credited more than once this way, so strip it and let AddPerson deduplicate. + var name = credit; + var annotation = name.IndexOf('(', StringComparison.Ordinal); + if (annotation >= 0) + { + name = name[..annotation]; + } + + name = name.Trim(); + if (name.Length == 0) { - Name = result.Writer.Trim(), - Type = PersonKind.Writer - }; + continue; + } + + // A generational suffix is separated from the name it belongs to by the same comma the list + // uses, e.g. "Jack Salvatore, Jr.", so it has to be joined back instead of becoming a credit. + if (names.Count > 0 && IsNameSuffix(name)) + { + names[^1] = names[^1] + ", " + name; + continue; + } - itemResult.AddPerson(person); + names.Add(name); } - if (!string.IsNullOrWhiteSpace(result.Actors)) + foreach (var name in names) { - var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var actor in actorList) + itemResult.AddPerson(new PersonInfo { - var person = new PersonInfo - { - Name = actor, - Type = PersonKind.Actor - }; + Name = name, + Type = type + }); + } + } + + // Only the commas between credits, never one inside an annotation: "Jerry Siegel (created by: + // Superman, Superboy)" is one credit, and splitting it blindly invents a person called "Superboy)". + private static IEnumerable<string> SplitCredits(string credits) + { + var depth = 0; + var start = 0; - itemResult.AddPerson(person); + for (var i = 0; i < credits.Length; i++) + { + switch (credits[i]) + { + case '(': + depth++; + break; + case ')': + depth = Math.Max(0, depth - 1); + break; + case ',' when depth == 0: + yield return credits[start..i]; + start = i + 1; + break; } } + + yield return credits[start..]; + } + + private static bool IsNameSuffix(string value) + { + var suffix = value.EndsWith('.') ? value[..^1] : value; + + return NameSuffixes.Contains(suffix, StringComparer.OrdinalIgnoreCase); } private static bool IsConfiguredForEnglish(BaseItem item, string language) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 78be5804e3..23f8d89c67 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -56,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + item.TryGetTmdbId(out var tmdbId); if (tmdbId <= 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index a7bba2d539..0a75b71264 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -42,7 +41,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + searchInfo.TryGetTmdbId(out var tmdbId); var language = searchInfo.MetadataLanguage; if (tmdbId > 0) @@ -97,7 +96,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// <inheritdoc /> public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + info.TryGetTmdbId(out var tmdbId); var language = info.MetadataLanguage; // We don't already have an Id, need to fetch it @@ -115,7 +114,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets } } - var result = new MetadataResult<BoxSet>(); + var result = new MetadataResult<BoxSet> + { + ResultLanguage = language + }; if (tmdbId > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 78405c21fc..c94a6455bc 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1819 // Properties should not return arrays + using MediaBrowser.Model.Plugins; namespace MediaBrowser.Providers.Plugins.Tmdb @@ -34,6 +36,51 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public bool ImportSeasonName { get; set; } /// <summary> + /// Gets or sets a value indicating whether unaired (upcoming) episodes should be created as + /// virtual items from the episode list provided by TMDb. These populate the "Upcoming" view. + /// Enabling this will increase scan times. + /// </summary> + public bool ImportUnairedEpisodes { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether already aired episodes that are not present in the + /// library should be created as virtual items from the episode list provided by TMDb. These + /// surface as missing episodes. Enabling this will increase scan times. + /// </summary> + public bool ImportMissingEpisodes { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether specials (season 0) should be included when creating + /// virtual unaired or missing episodes. When disabled, specials are never added and any existing + /// virtual specials created by this provider are removed. + /// </summary> + public bool ImportSpecials { get; set; } + + /// <summary> + /// Gets or sets the ids (the "N" formatted GUIDs from <c>VirtualFolderInfo.ItemId</c>) of the + /// libraries for which the unaired/missing episode provider is enabled. Whether episodes are + /// imported at all, and how, is still controlled by the global toggles above; those toggles only + /// apply to the libraries listed here. Libraries not listed, including newly added ones, are + /// never processed, so an empty list disables the feature entirely. + /// </summary> + public string[] EnabledMissingEpisodeLibraries { get; set; } = []; + + /// <summary> + /// Gets or sets how often, in days, the scheduled task re-checks TMDb for newly announced + /// unaired or missing episodes. This is what keeps the "Upcoming" view current for series + /// whose local files have not changed. + /// </summary> + public int MissingEpisodeRefreshIntervalDays { get; set; } = 7; + + /// <summary> + /// Gets or sets the number of days a virtual episode is retained after it airs before it is + /// pruned (when missing episode import is disabled). This grace period leaves recently aired + /// episodes in place to allow for the delay between an episode airing and its file being added + /// to the library. + /// </summary> + public int UpcomingEpisodeGracePeriodDays { get; set; } = 7; + + /// <summary> /// Gets or sets a value indicating the maximum number of cast members to fetch for an item. /// </summary> public int MaxCastMembers { get; set; } = 15; @@ -81,6 +128,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <summary> /// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching. /// </summary> - public int SimilarItemsCacheDays { get; set; } = 7; + public int SimilarItemsCacheDays { get; set; } = 90; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 4048fc1655..582753759f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -25,6 +25,40 @@ <input is="emby-checkbox" type="checkbox" id="importSeasonName" /> <span>Import season name from metadata fetched for series.</span> </label> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importUnairedEpisodes" /> + <span>Create unaired (upcoming) episodes from metadata fetched for series.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have not aired yet. This populates the "Upcoming" view. Specials are never added to the "Upcoming" view.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importMissingEpisodes" /> + <span>Create missing episodes from metadata fetched for series.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have already aired but are not present in your library. Missing episodes are only shown when enabled in the user display preferences. Both options increase scan times.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importSpecials" /> + <span>Include specials when creating unaired and missing episodes.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">When disabled, specials (season 0) are never added as virtual entries and any existing virtual specials are removed.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="number" id="missingEpisodeRefreshIntervalDays" pattern="[0-9]*" required min="1" max="365" label="Episode refresh interval (days)" /> + <div class="fieldDescription">How often the scheduled task re-checks TMDb for newly announced unaired or missing episodes. Keeps the "Upcoming" view current for series whose local files have not changed.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="number" id="upcomingEpisodeGracePeriodDays" pattern="[0-9]*" required min="0" max="365" label="Recently aired grace period (days)" /> + <div class="fieldDescription">When missing episodes are disabled, how many days a recently aired episode is kept in place before its placeholder is removed. This allows for the delay between an episode airing and its file being added to the library.</div> + </div> + <div class="verticalSection"> + <h2>Libraries</h2> + <div class="fieldDescription" style="margin-bottom:1em;">Choose which TV libraries the unaired/missing episode options above apply to. The settings above are global; this only controls which libraries they run for. Libraries are opted in individually, so newly added libraries are not processed until they are enabled here.</div> + <div id="missingEpisodeLibraries"></div> + </div> <div class="verticalSection"> <h2>Cast & Crew Settings</h2> <div class="inputContainer"> @@ -85,6 +119,29 @@ Dashboard.showLoadingMsg(); var clientConfig, pluginConfig; + var populateMissingEpisodeLibraries = function (enabledLibraries) { + var container = document.querySelector('#missingEpisodeLibraries'); + ApiClient.getVirtualFolders().then(function (folders) { + // Series only live in TV libraries (and mixed-content libraries, which report + // no collection type), so only those are worth listing here. + var tvLibraries = folders.filter(function (folder) { + return !folder.CollectionType || folder.CollectionType === 'tvshows'; + }); + + if (tvLibraries.length === 0) { + container.innerHTML = '<div class="fieldDescription">No TV libraries found.</div>'; + return; + } + + container.innerHTML = tvLibraries.map(function (folder) { + var checked = enabledLibraries.indexOf(folder.ItemId) === -1 ? '' : ' checked'; + return '<label class="checkboxContainer">' + + '<input is="emby-checkbox" type="checkbox" class="missingEpisodeLibrary" data-library-id="' + folder.ItemId + '"' + checked + ' />' + + '<span>' + folder.Name + '</span>' + + '</label>'; + }).join(''); + }); + } var configureImageScaling = function() { if (clientConfig === undefined || pluginConfig === undefined) { return; @@ -151,9 +208,16 @@ document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries; document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies; document.querySelector('#importSeasonName').checked = config.ImportSeasonName; + document.querySelector('#importUnairedEpisodes').checked = config.ImportUnairedEpisodes; + document.querySelector('#importMissingEpisodes').checked = config.ImportMissingEpisodes; + document.querySelector('#importSpecials').checked = config.ImportSpecials; + document.querySelector('#missingEpisodeRefreshIntervalDays').value = config.MissingEpisodeRefreshIntervalDays; + document.querySelector('#upcomingEpisodeGracePeriodDays').value = config.UpcomingEpisodeGracePeriodDays; document.querySelector('#hideMissingCastMembers').checked = config.HideMissingCastMembers; document.querySelector('#hideMissingCrewMembers').checked = config.HideMissingCrewMembers; + populateMissingEpisodeLibraries(config.EnabledMissingEpisodeLibraries || []); + var maxCastMembers = document.querySelector('#maxCastMembers'); maxCastMembers.value = config.MaxCastMembers; maxCastMembers.dispatchEvent(new Event('change', { @@ -189,6 +253,17 @@ config.ExcludeTagsSeries = document.querySelector('#excludeTagsSeries').checked; config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked; config.ImportSeasonName = document.querySelector('#importSeasonName').checked; + config.ImportUnairedEpisodes = document.querySelector('#importUnairedEpisodes').checked; + config.ImportMissingEpisodes = document.querySelector('#importMissingEpisodes').checked; + config.ImportSpecials = document.querySelector('#importSpecials').checked; + config.MissingEpisodeRefreshIntervalDays = parseInt(document.querySelector('#missingEpisodeRefreshIntervalDays').value, 10); + config.UpcomingEpisodeGracePeriodDays = parseInt(document.querySelector('#upcomingEpisodeGracePeriodDays').value, 10); + var libraryCheckboxes = document.querySelectorAll('.missingEpisodeLibrary'); + if (libraryCheckboxes.length > 0) { + config.EnabledMissingEpisodeLibraries = Array.prototype.filter + .call(libraryCheckboxes, function (checkbox) { return checkbox.checked; }) + .map(function (checkbox) { return checkbox.getAttribute('data-library-id'); }); + } config.MaxCastMembers = document.querySelector('#maxCastMembers').value; config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value; config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index b188f5deb4..e686577311 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -61,7 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var language = item.GetPreferredMetadataLanguage(); var countryCode = item.GetPreferredMetadataCountryCode(); - var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + item.TryGetTmdbId(out var movieTmdbId); if (movieTmdbId <= 0) { var movieImdbId = item.GetProviderId(MetadataProvider.Imdb); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 8811a1787a..9501da7ec6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -54,11 +54,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { - if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var id)) + if (searchInfo.TryGetTmdbId(out var tmdbId)) { var movie = await _tmdbClientManager .GetMovieAsync( - int.Parse(id, CultureInfo.InvariantCulture), + tmdbId, searchInfo.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), searchInfo.MetadataCountryCode, @@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies } IReadOnlyList<SearchMovie>? movieResults = null; - if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id)) + if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var id)) { var result = await _tmdbClientManager.FindByExternalIdAsync( id, @@ -151,41 +151,46 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies /// <inheritdoc /> public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { - var tmdbId = info.GetProviderId(MetadataProvider.Tmdb); + // A stored id that is not a TMDb id is treated as no id, so the search below can repair it + // rather than the lookup failing for as long as the bad id stays on the item. + info.TryGetTmdbId(out var tmdbId); var imdbId = info.GetProviderId(MetadataProvider.Imdb); var config = Plugin.Instance.Configuration; - if (string.IsNullOrEmpty(tmdbId) && string.IsNullOrEmpty(imdbId)) + if (tmdbId <= 0 && string.IsNullOrEmpty(imdbId)) { // ParseName is required here. // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); + var searchYear = info.Year ?? parsedName.Year ?? 0; - var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, searchYear, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResults?.Count > 0) + var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear); + + if (match is not null) { - tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); + tmdbId = match.Id; } } - if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId)) + if (tmdbId <= 0 && !string.IsNullOrEmpty(imdbId)) { var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (movieResultFromImdbId?.MovieResults?.Count > 0) { - tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); + tmdbId = movieResultFromImdbId.MovieResults[0].Id; } } - if (string.IsNullOrEmpty(tmdbId)) + if (tmdbId <= 0) { return new MetadataResult<Movie>(); } var movieResult = await _tmdbClientManager - .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) + .GetMovieAsync(tmdbId, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (movieResult is null) @@ -208,7 +213,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies Item = movie }; - movie.SetProviderId(MetadataProvider.Tmdb, tmdbId); + movie.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString(CultureInfo.InvariantCulture)); movie.TrySetProviderId(MetadataProvider.Imdb, movieResult.ImdbId); if (movieResult.BelongsToCollection is not null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs index 5206de78ce..6a3c72d2fa 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs @@ -58,7 +58,7 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> } var providerName = MetadataProvider.Tmdb.ToString(); - var page = 0; + var page = 1; var totalPages = 1; while (page <= totalPages && !cancellationToken.IsCancellationRequested) @@ -67,12 +67,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> try { (pageResults, totalPages) = await _tmdbClientManager - .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .GetMovieRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page); + _logger.LogWarning(ex, "Failed to get recommended movies from TMDb for {TmdbId} page {Page}", tmdbId, page); yield break; } @@ -81,12 +81,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> yield break; } - foreach (var similar in pageResults) + foreach (var recommendation in pageResults) { yield return new SimilarItemReference { ProviderName = providerName, - ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture) }; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index 33888ddf4f..d38614811c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -54,14 +53,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People { var person = (Person)item; - if (!person.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) + if (!person.TryGetTmdbId(out var personTmdbId)) { return Enumerable.Empty<RemoteImageInfo>(); } var language = item.GetPreferredMetadataLanguage(); var countryCode = item.GetPreferredMetadataCountryCode(); - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false); + var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, language, countryCode, cancellationToken).ConfigureAwait(false); if (personResult?.Images?.Profiles is null) { return Enumerable.Empty<RemoteImageInfo>(); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 64ab98b262..695f347a9a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; @@ -37,9 +36,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken) { - if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) + if (searchInfo.TryGetTmdbId(out var personTmdbId)) { - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (personResult is not null) { @@ -89,7 +88,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People /// <inheritdoc /> public async Task<MetadataResult<Person>> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken) { - var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + // A person can carry another provider's id under the TMDb key, which is no more usable here + // than no id at all, so both take the search path and get the stored id repaired. + info.TryGetTmdbId(out var personTmdbId); // We don't already have an Id, need to fetch it if (personTmdbId <= 0) @@ -101,7 +102,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } } - var result = new MetadataResult<Person>(); + var result = new MetadataResult<Person> + { + ResultLanguage = info.MetadataLanguage + }; if (personTmdbId > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 7ae54cdcd3..1f8c87397d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -56,9 +54,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var episode = (Controller.Entities.TV.Episode)item; var series = episode.Series; - var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var seriesTmdbId = 0; - if (series is null || seriesTmdbId <= 0) + if (series?.TryGetTmdbId(out seriesTmdbId) != true) { return Enumerable.Empty<RemoteImageInfo>(); } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 21b822c97c..8172ab14df 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -91,8 +91,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? tmdbId); - var seriesTmdbId = Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture); - if (seriesTmdbId <= 0) + if (!TmdbUtils.TryParseTmdbId(tmdbId, out var seriesTmdbId)) { return metadataResult; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs new file mode 100644 index 0000000000..b44361e4e7 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs @@ -0,0 +1,663 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using TMDbLib.Objects.Search; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// Creates virtual (metadata-only) entries for missing and unaired episodes. + /// </summary> + public class TmdbMissingEpisodeProvider : ICustomMetadataProvider<Series>, IHasItemChangeMonitor, IHasOrder + { + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; + private readonly ILogger<TmdbMissingEpisodeProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbMissingEpisodeProvider"/> class. + /// </summary> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="providerManager">The <see cref="IProviderManager"/>.</param> + /// <param name="logger">The <see cref="ILogger{TmdbMissingEpisodeProvider}"/>.</param> + public TmdbMissingEpisodeProvider( + TmdbClientManager tmdbClientManager, + ILibraryManager libraryManager, + IFileSystem fileSystem, + IProviderManager providerManager, + ILogger<TmdbMissingEpisodeProvider> logger) + { + _tmdbClientManager = tmdbClientManager; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _providerManager = providerManager; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc /> + // Run after the remote series provider so the TMDb id and other metadata are available. + public int Order => 100; + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + // Reporting a change makes this provider (and only this provider) run during an otherwise incremental refresh. + if (Plugin.Instance?.Configuration is null) + { + return false; + } + + return item is Series series && series.HasProviderId(MetadataProvider.Tmdb); + } + + /// <inheritdoc /> + public async Task<ItemUpdateType> FetchAsync(Series item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var configuration = Plugin.Instance?.Configuration; + var importUnaired = (configuration?.ImportUnairedEpisodes).GetValueOrDefault(); + var importMissing = (configuration?.ImportMissingEpisodes).GetValueOrDefault(); + + // The provider is inactive for this series when both global imports are off, or the series' + // library has not been opted in. In either case remove every virtual episode (unaired and + // missing alike) it previously created, so disabling the feature cleans up on the next scan. + if ((!importUnaired && !importMissing) || !IsEnabledForLibrary(item)) + { + if (!PruneAllVirtualEpisodes(item)) + { + return ItemUpdateType.None; + } + + item.Children = null; + return ItemUpdateType.MetadataImport; + } + + var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); + if (string.IsNullOrEmpty(tmdbId) + || !int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seriesTmdbId) + || seriesTmdbId <= 0) + { + return ItemUpdateType.None; + } + + var language = item.GetPreferredMetadataLanguage(); + var countryCode = item.GetPreferredMetadataCountryCode(); + var imageLanguages = TmdbUtils.GetImageLanguagesParam(language, countryCode); + + var tmdbSeries = await _tmdbClientManager + .GetSeriesAsync(seriesTmdbId, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeries?.Seasons is null) + { + return ItemUpdateType.None; + } + + var today = DateTime.UtcNow.Date; + + var importSpecials = (configuration?.ImportSpecials).GetValueOrDefault(); + var gracePeriodDays = Math.Max(0, (configuration?.UpcomingEpisodeGracePeriodDays).GetValueOrDefault()); + + // Track every (season, episode) number that already exists (physical or virtual) so we never + // create a duplicate. + // When missing episodes are disabled, this pass also prunes virtual episodes that aired more + // than the grace period ago, as well as any specials when specials are not wanted. + var (existingEpisodes, updatableEpisodes) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes); + + var seasonsByNumber = item.GetRecursiveChildren(i => i is Season) + .OfType<Season>() + .Where(s => s.IndexNumber.HasValue) + .GroupBy(s => s.IndexNumber!.Value) + .ToDictionary(g => g.Key, g => g.First()); + + var addedEpisodes = false; + var updatedEpisodes = false; + + foreach (var seasonInfo in tmdbSeries.Seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + var seasonNumber = seasonInfo.SeasonNumber; + var tmdbSeason = await _tmdbClientManager + .GetSeasonAsync(seriesTmdbId, seasonNumber, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeason?.Episodes is null) + { + continue; + } + + foreach (var tmdbEpisode in tmdbSeason.Episodes) + { + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + var premiereDate = GetPremiereDate(tmdbEpisode); + + // Skips undated episodes, unaired (upcoming) ones unless upcoming import is enabled, + // already aired ones unless missing import is enabled, and unaired specials entirely. + if (!ShouldImportEpisode(premiereDate, today, importUnaired, importMissing, seasonNumber == 0, importSpecials)) + { + continue; + } + + var key = (seasonNumber, episodeNumber); + + // Already have a virtual episode this provider created, keep metadata in sync with TMDb. + if (updatableEpisodes.TryGetValue(key, out var existingEpisode)) + { + var season = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); + var changed = UpdateVirtualEpisode(existingEpisode, tmdbEpisode, premiereDate); + + if (!existingEpisode.ParentId.Equals(season.Id)) + { + existingEpisode.SetParent(season); + existingEpisode.SeasonId = season.Id; + existingEpisode.SeasonName = season.Name; + changed = true; + } + + if (string.IsNullOrEmpty(existingEpisode.PresentationUniqueKey)) + { + existingEpisode.PresentationUniqueKey = existingEpisode.CreatePresentationUniqueKey(); + changed = true; + } + + if (changed) + { + await existingEpisode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + updatedEpisodes = true; + } + + // Backfill the still for placeholders created before images were fetched. + if (await EnsureEpisodeImageAsync(existingEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false)) + { + updatedEpisodes = true; + } + + continue; + } + + if (!existingEpisodes.Add(key)) + { + continue; + } + + var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); + var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); + await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false); + addedEpisodes = true; + } + } + + var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).ConfigureAwait(false); + + if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons) + { + return ItemUpdateType.None; + } + + // Invalidate the cached children so that the season creation / cleanup that runs later in + // SeriesMetadataService.AfterMetadataRefresh observes the newly created (and pruned) episodes. + item.Children = null; + + return ItemUpdateType.MetadataImport; + } + + /// <summary> + /// Returns the series' season with the given number, creating (and refreshing) a virtual season + /// when the whole season is missing from the library. + /// </summary> + private async Task<Season> GetOrCreateSeasonAsync(Series series, int seasonNumber, string? seasonName, Dictionary<int, Season> seasonsByNumber, CancellationToken cancellationToken) + { + if (seasonsByNumber.TryGetValue(seasonNumber, out var existingSeason)) + { + return existingSeason; + } + + _logger.LogInformation("Creating virtual season {SeasonNumber} for series {SeriesName}", seasonNumber, series.Name); + + var season = new Season + { + Name = seasonName, + IndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture), + typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + series.AddChild(season); + await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false); + + seasonsByNumber[seasonNumber] = season; + return season; + } + + /// <summary> + /// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by + /// number instead of jumping ahead. See <see cref="BuildSeasonSortNameTemplate"/> for the details. + /// </summary> + /// <param name="seasons">The series' seasons (physical and virtual).</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if any virtual season was updated; otherwise <c>false</c>.</returns> + private async Task<bool> AlignVirtualSeasonSortNamesAsync(IEnumerable<Season> seasons, CancellationToken cancellationToken) + { + var seasonList = seasons.ToList(); + var template = BuildSeasonSortNameTemplate(seasonList); + if (template is null) + { + // No physical season sorts by name: virtual seasons already share the bare-index key space. + return false; + } + + var updated = false; + foreach (var season in seasonList) + { + if (!season.IsVirtualItem || !season.IndexNumber.HasValue) + { + continue; + } + + var desired = template(season.IndexNumber.Value); + if (string.Equals(season.ForcedSortName, desired, StringComparison.Ordinal)) + { + continue; + } + + _logger.LogInformation( + "Aligning sort name of virtual season {SeasonNumber} in series {SeriesName} to {SortName}", + season.IndexNumber, + season.SeriesName, + desired); + + season.ForcedSortName = desired; + await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + updated = true; + } + + return updated; + } + + /// <summary> + /// Builds a factory that maps a season number to a forced sort name mirroring a physical, + /// name-sorted sibling season, or <c>null</c> when no physical season sorts by name. + /// </summary> + /// <param name="seasons">The series' seasons (physical and virtual).</param> + /// <returns>A season-number-to-sort-name factory, or <c>null</c> if there is nothing to mirror.</returns> + internal static Func<int, string>? BuildSeasonSortNameTemplate(IEnumerable<Season> seasons) + { + // Season.CreateSortName sorts by the bare padded index ("0003"), but season NFOs give physical + // seasons a name-based forced sort ("Season 01" -> "season 0000000001"). The digit-leading key + // sorts ahead of the letter-leading one, so mirror the sibling's token with each season number. + var reference = seasons.FirstOrDefault(s => + !s.IsVirtualItem && s.IndexNumber.HasValue && !string.IsNullOrEmpty(s.ForcedSortName)); + if (reference is null) + { + return null; + } + + var forced = reference.ForcedSortName!; + + // Locate the last run of digits (the season number) in the sibling's forced sort name. + var end = -1; + var start = -1; + for (var i = forced.Length - 1; i >= 0; i--) + { + if (char.IsDigit(forced[i])) + { + end = end < 0 ? i : end; + start = i; + } + else if (end >= 0) + { + break; + } + } + + if (end < 0) + { + // Sibling has no numeric component to swap; leave virtual seasons on the bare-index key. + return null; + } + + var prefix = forced[..start]; + var suffix = forced[(end + 1)..]; + var width = end - start + 1; + + // The exact zero-padding is cosmetic: ModifySortChunks pads every digit run to 10 characters, + // so "Season 3" and "Season 03" collapse to the same sort key. Keeping the sibling's width just + // makes the stored value read naturally. + return number => prefix + + number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0') + + suffix; + } + + private bool IsEnabledForLibrary(BaseItem item) + { + var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries; + if (enabledLibraries is null || enabledLibraries.Length == 0) + { + return false; + } + + // A series can live under more than one collection folder; opting in any one of them is + // enough. An item that belongs to no collection folder cannot be opted in at all. + return _libraryManager.GetCollectionFolders(item).Any(folder => + enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); + } + + private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned) + { + var keys = new HashSet<(int Season, int Episode)>(); + var updatable = new Dictionary<(int Season, int Episode), Episode>(); + var physicalKeys = new HashSet<(int Season, int Episode)>(); + var ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>(); + pruned = false; + + // Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes' + // SeriesPresentationUniqueKey is not set yet, so the presentation-key based query would miss + // them. GetRecursiveChildren walks the actual child tree and sees them regardless. + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>()) + { + // The series is refreshed before its episodes during an initial scan, so a freshly + // resolved physical episode may not have its numbers populated yet. Resolve them from + // the path (in memory, mirroring CreateSeasonsAsync) so we can dedupe against episodes + // the user actually has files for instead of creating virtual duplicates. + if (episode.IsFileProtocol && (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue)) + { + try + { + _libraryManager.FillMissingEpisodeNumbersFromPath(episode, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resolving episode number from path for {Path}", episode.Path); + } + } + + // Virtual episodes this provider created are candidates for metadata sync (and pruning). + var isOurs = episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb); + + if (ShouldPrune(episode, pruneAgedOut, today, gracePeriodDays, importSpecials)) + { + DeleteEpisode(episode, "no longer upcoming and missing episodes are disabled"); + pruned = true; + continue; + } + + if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue) + { + var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value); + keys.Add(key); + + // Defer the ours/physical reconciliation: an episode's virtual counterpart and its + // physical file can appear in either order while walking the tree, so we can only + // decide which of our virtual episodes are superseded once every episode is seen. + if (isOurs) + { + ourVirtuals.Add((key, episode)); + } + else if (!episode.IsVirtualItem) + { + physicalKeys.Add(key); + } + } + } + + // A physical file now exists for one of our placeholders: delete the placeholder here rather + // than updating it (and then leaving RemoveObsoleteEpisodes to delete it moments later). The + // physical key already blocks re-creation via the dedupe set above. + foreach (var (key, episode) in ourVirtuals) + { + if (physicalKeys.Contains(key)) + { + DeleteEpisode(episode, "a physical episode now exists for this slot"); + pruned = true; + } + else + { + // Virtual episodes this provider created are candidates for metadata sync. + updatable[key] = episode; + } + } + + return (keys, updatable); + } + + /// <summary> + /// Removes every virtual episode this provider previously created in the series. + /// </summary> + /// <param name="series">The series to clean up.</param> + /// <returns><c>true</c> if any episode was removed; otherwise <c>false</c>.</returns> + private bool PruneAllVirtualEpisodes(Series series) + { + var pruned = false; + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>()) + { + if (episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb)) + { + DeleteEpisode(episode, "the TMDb missing episode provider is disabled for this library"); + pruned = true; + } + } + + return pruned; + } + + private void DeleteEpisode(Episode episode, string reason) + { + _logger.LogInformation( + "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}", + episode.ParentIndexNumber, + episode.IndexNumber, + episode.SeriesName, + reason); + + _libraryManager.DeleteItem( + episode, + new DeleteOptions { DeleteFileLocation = false }, + false); + } + + /// <summary> + /// Determines whether a TMDb episode should be imported as a virtual item, based on its air date + /// and the enabled options. Undated episodes are never imported; unaired (today or later) episodes + /// require <paramref name="importUnaired"/>; already aired episodes require <paramref name="importMissing"/>. + /// Specials (season 0) are only imported when <paramref name="importSpecials"/> is enabled. + /// </summary> + /// <param name="premiereDate">The episode air date (UTC), or null if unknown.</param> + /// <param name="today">The current UTC date.</param> + /// <param name="importUnaired">Whether unaired (upcoming) episodes should be imported.</param> + /// <param name="importMissing">Whether already aired missing episodes should be imported.</param> + /// <param name="isSpecial">Whether the episode belongs to the specials season (season 0).</param> + /// <param name="importSpecials">Whether specials should be included.</param> + /// <returns><c>true</c> if the episode should be imported; otherwise <c>false</c>.</returns> + internal static bool ShouldImportEpisode(DateTime? premiereDate, DateTime today, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials) + { + if (!premiereDate.HasValue) + { + return false; + } + + // Specials are only imported when the user opts in. + if (isSpecial && !importSpecials) + { + return false; + } + + var isUnaired = premiereDate.Value.Date >= today; + return isUnaired ? importUnaired : importMissing; + } + + /// <summary> + /// Determines whether an existing virtual episode created by this provider (carries a TMDb id) + /// should be pruned. Specials are removed entirely unless <paramref name="importSpecials"/> is + /// enabled. Otherwise, when missing episodes are not wanted, an entry is pruned once its air date + /// is more than <paramref name="gracePeriodDays"/> in the past; the grace period keeps recently + /// aired episodes in place to allow for the delay between an episode airing and its file being + /// added to the library. + /// </summary> + /// <param name="episode">The episode to evaluate.</param> + /// <param name="pruneAgedOut">Whether aged-out virtual episodes should be pruned (missing import disabled).</param> + /// <param name="today">The current UTC date.</param> + /// <param name="gracePeriodDays">The number of days an aired episode is retained before pruning.</param> + /// <param name="importSpecials">Whether specials should be kept.</param> + /// <returns><c>true</c> if the episode should be pruned; otherwise <c>false</c>.</returns> + internal static bool ShouldPrune(Episode episode, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials) + { + if (!episode.IsVirtualItem || !episode.HasProviderId(MetadataProvider.Tmdb)) + { + return false; + } + + // Specials are removed entirely unless the user opts in. + if (episode.ParentIndexNumber == 0 && !importSpecials) + { + return true; + } + + // When missing episodes are not wanted, prune placeholders for episodes that aired more than + // the grace period ago. + return pruneAgedOut + && episode.PremiereDate.HasValue + && episode.PremiereDate.Value.Date < today.AddDays(-gracePeriodDays); + } + + internal static DateTime? GetPremiereDate(TvSeasonEpisode tmdbEpisode) + { + return tmdbEpisode.AirDate.HasValue + ? DateTime.SpecifyKind(tmdbEpisode.AirDate.Value, DateTimeKind.Local).ToUniversalTime() + : null; + } + + internal static bool UpdateVirtualEpisode(Episode episode, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var changed = false; + + if (!string.IsNullOrEmpty(tmdbEpisode.Name) && !string.Equals(episode.Name, tmdbEpisode.Name, StringComparison.Ordinal)) + { + episode.Name = tmdbEpisode.Name; + changed = true; + } + + if (!string.IsNullOrEmpty(tmdbEpisode.Overview) && !string.Equals(episode.Overview, tmdbEpisode.Overview, StringComparison.Ordinal)) + { + episode.Overview = tmdbEpisode.Overview; + changed = true; + } + + if (premiereDate.HasValue && episode.PremiereDate != premiereDate) + { + episode.PremiereDate = premiereDate; + episode.ProductionYear = tmdbEpisode.AirDate?.Year; + changed = true; + } + + return changed; + } + + private Episode AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var seasonNumber = season.IndexNumber.GetValueOrDefault(); + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + + // Leaving Path unset makes the item a virtual (metadata-only) episode. + var episode = new Episode + { + Name = tmdbEpisode.Name, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture) + + "Episode" + episodeNumber.ToString(CultureInfo.InvariantCulture), + typeof(Episode)), + IsVirtualItem = true, + PremiereDate = premiereDate, + ProductionYear = tmdbEpisode.AirDate?.Year, + Overview = tmdbEpisode.Overview, + SeasonId = season.Id, + SeasonName = season.Name, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + episode.PresentationUniqueKey = episode.CreatePresentationUniqueKey(); + + if (tmdbEpisode.Id > 0) + { + episode.SetProviderId(MetadataProvider.Tmdb, tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture)); + } + + _logger.LogInformation( + "Creating virtual episode S{SeasonNumber}E{EpisodeNumber} for series {SeriesName}", + seasonNumber, + episodeNumber, + series.Name); + + season.AddChild(episode); + + return episode; + } + + /// <summary> + /// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back + /// to the season/series image. + /// </summary> + /// <param name="episode">The virtual episode.</param> + /// <param name="tmdbEpisode">The matching TMDb episode.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><c>true</c> if a still was downloaded and saved; otherwise <c>false</c>.</returns> + private async Task<bool> EnsureEpisodeImageAsync(Episode episode, TvSeasonEpisode tmdbEpisode, CancellationToken cancellationToken) + { + // The still ships with the season episode list, so use it directly instead of a per-episode lookup. + if (episode.HasImage(ImageType.Primary, 0) || string.IsNullOrEmpty(tmdbEpisode.StillPath)) + { + return false; + } + + var stillUrl = _tmdbClientManager.GetStillUrl(tmdbEpisode.StillPath); + if (string.IsNullOrEmpty(stillUrl)) + { + return false; + } + + try + { + // SaveImage sets the image path on the item but does not persist it, so save afterwards. + await _providerManager.SaveImage(episode, stillUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false); + await episode.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error downloading still for virtual episode S{SeasonNumber}E{EpisodeNumber} of {SeriesName}", + episode.ParentIndexNumber, + episode.IndexNumber, + episode.SeriesName); + return false; + } + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index 5b2f0d26e4..bc44d0266d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -57,9 +55,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var season = (Season)item; var series = season?.Series; - var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var seriesTmdbId = 0; - if (seriesTmdbId <= 0 || season?.IndexNumber is null) + if (season?.IndexNumber is null || series?.TryGetTmdbId(out seriesTmdbId) != true) { return Enumerable.Empty<RemoteImageInfo>(); } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 1eb522137d..9b8803f171 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -41,20 +40,23 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// <inheritdoc /> public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult<Season>(); + var result = new MetadataResult<Season> + { + ResultLanguage = info.MetadataLanguage + }; var config = Plugin.Instance.Configuration; info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId); var seasonNumber = info.IndexNumber; - if (string.IsNullOrWhiteSpace(seriesTmdbId) || !seasonNumber.HasValue) + if (!seasonNumber.HasValue || !TmdbUtils.TryParseTmdbId(seriesTmdbId, out var seriesId)) { return result; } var seasonResult = await _tmdbClientManager - .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) + .GetSeasonAsync(seriesId, seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (seasonResult is null) @@ -76,11 +78,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item.Name = seasonResult.Name; } + result.Item.TrySetProviderId(MetadataProvider.Tmdb, seasonResult.Id?.ToString(CultureInfo.InvariantCulture)); result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId); - // TODO why was this disabled? var credits = seasonResult.Credits; - if (credits?.Cast is not null) { var castQuery = config.HideMissingCastMembers diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index f2e7d0c6e4..dc4f860604 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -57,9 +55,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { - var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); - - if (string.IsNullOrEmpty(tmdbId)) + if (!item.TryGetTmdbId(out var tmdbId)) { return Enumerable.Empty<RemoteImageInfo>(); } @@ -68,7 +64,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken) + .GetSeriesAsync(tmdbId, null, null, null, cancellationToken) .ConfigureAwait(false); if (series?.Images is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index b44e54f888..b6bb7aa013 100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -54,10 +54,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { - if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)) + if (searchInfo.TryGetTmdbId(out var tmdbId)) { var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) + .GetSeriesAsync(tmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (series is not null) @@ -202,11 +202,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); + var searchYear = info.Year ?? parsedName.Year ?? 0; + var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, searchYear, cancellationToken).ConfigureAwait(false); - if (searchResults?.Count > 0) + var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear); + + if (match is not null) { - tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); + tmdbId = match.Id.ToString(CultureInfo.InvariantCulture); } } @@ -329,13 +332,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (seriesResult.Videos?.Results is not null) { - foreach (var video in seriesResult.Videos.Results) + var trailers = new List<MediaUrl>(); + + var sortedVideos = seriesResult.Videos.Results + .OrderByDescending(video => string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase)); + + foreach (var video in sortedVideos) { - if (TmdbUtils.IsTrailerType(video)) + if (!TmdbUtils.IsTrailerType(video)) { - series.AddTrailerUrl("https://www.youtube.com/watch?v=" + video.Key); + continue; } + + trailers.Add(new MediaUrl + { + Url = string.Format(CultureInfo.InvariantCulture, "https://www.youtube.com/watch?v={0}", video.Key), + Name = video.Name + }); } + + series.RemoteTrailers = trailers; } if (!string.IsNullOrEmpty(seriesResult.OriginalLanguage)) @@ -350,39 +366,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { var config = Plugin.Instance.Configuration; - if (seriesResult.Credits?.Cast is not null) - { - IEnumerable<Cast> castQuery = seriesResult.Credits.Cast.OrderBy(a => a.Order); - - if (config.HideMissingCastMembers) - { - castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath)); - } + // The aggregated credits are what hold an actor's several characters apart; the flat ones + // put them in a single string. Only the aggregated list carries the whole run, so prefer it + // and fall back for the rare show TMDb has no aggregation for. + var cast = seriesResult.AggregateCredits?.Cast is { Count: > 0 } aggregated + ? TmdbUtils.MapAggregateCast(aggregated, config, _tmdbClientManager.GetProfileUrl) + : TmdbUtils.MapCast(seriesResult.Credits?.Cast, config, _tmdbClientManager.GetProfileUrl); - foreach (var actor in castQuery.Take(config.MaxCastMembers)) - { - if (string.IsNullOrWhiteSpace(actor.Name)) - { - continue; - } - - var personInfo = new PersonInfo - { - Name = actor.Name.Trim(), - Role = actor.Character?.Trim() ?? string.Empty, - Type = PersonKind.Actor, - SortOrder = actor.Order, - // NOTE: Null values are filtered out above - ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!) - }; - - if (actor.Id > 0) - { - personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture)); - } - - yield return personInfo; - } + foreach (var actor in cast) + { + yield return actor; } if (seriesResult.Credits?.Crew is not null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs index c85718b993..40c7de05ad 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs @@ -67,12 +67,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> try { (pageResults, totalPages) = await _tmdbClientManager - .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .GetSeriesRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); + _logger.LogWarning(ex, "Failed to get recommended TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); yield break; } @@ -81,12 +81,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> yield break; } - foreach (var similar in pageResults) + foreach (var recommendation in pageResults) { yield return new SimilarItemReference { ProviderName = providerName, - ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture) }; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs new file mode 100644 index 0000000000..e2846c74a3 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// Scheduled task that re-checks TMDb for newly announced unaired and missing episodes and creates + /// the corresponding virtual items. This keeps the "Upcoming" view current for series whose local + /// files have not changed, which an ordinary library scan would never re-examine. + /// </summary> + public class TmdbUpcomingEpisodesTask : IScheduledTask + { + private const int DefaultIntervalDays = 7; + + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger<TmdbUpcomingEpisodesTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbUpcomingEpisodesTask"/> class. + /// </summary> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="logger">The <see cref="ILogger{TmdbUpcomingEpisodesTask}"/>.</param> + public TmdbUpcomingEpisodesTask( + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger<TmdbUpcomingEpisodesTask> logger) + { + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Refresh upcoming and missing episodes (TheMovieDb)"; + + /// <inheritdoc /> + public string Description => "Checks TheMovieDb for newly announced episodes and creates virtual entries for unaired and missing episodes, according to the TMDb plugin settings. When both options are disabled, removes any virtual entries previously created."; + + /// <inheritdoc /> + public string Category => "Library"; + + /// <inheritdoc /> + public string Key => "TmdbRefreshUpcomingEpisodes"; + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + var intervalDays = Plugin.Instance?.Configuration.MissingEpisodeRefreshIntervalDays ?? DefaultIntervalDays; + if (intervalDays <= 0) + { + intervalDays = DefaultIntervalDays; + } + + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfoType.IntervalTrigger, + IntervalTicks = TimeSpan.FromDays(intervalDays).Ticks + }; + } + + /// <inheritdoc /> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var configuration = Plugin.Instance?.Configuration; + if (configuration is null) + { + progress.Report(100); + return; + } + + // The feature is fully disabled: remove every virtual episode (and now-empty virtual season) + // this provider previously created, across all libraries, then stop. + if ((!configuration.ImportUnairedEpisodes && !configuration.ImportMissingEpisodes) + || configuration.EnabledMissingEpisodeLibraries.Length == 0) + { + RemoveAllVirtualItems(progress, cancellationToken); + return; + } + + // Process non-ended series (they may have gained episodes) plus any series in a library that + // is not opted in (regardless of status) so the provider can prune the virtual episodes it + // previously created there. Ended series in enabled libraries cannot change, so they're skipped. + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series], + Recursive = true + }) + .OfType<Series>() + .Where(s => s.HasProviderId(MetadataProvider.Tmdb) + && (s.Status != SeriesStatus.Ended || !IsEnabledForLibrary(s))) + .ToList(); + + if (series.Count == 0) + { + progress.Report(100); + return; + } + + // ValidateChildren (rather than a bare RefreshMetadata) is required so the created episodes + // are immediately visible. + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.Default, + ImageRefreshMode = MetadataRefreshMode.ValidationOnly, + IsAutomated = true + }; + + for (var i = 0; i < series.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await series[i].ValidateChildren(new Progress<double>(), refreshOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing upcoming episodes for series {SeriesName}", series[i].Name); + } + + progress.Report(100.0 * (i + 1) / series.Count); + } + } + + private bool IsEnabledForLibrary(BaseItem item) + { + var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries; + if (enabledLibraries is null || enabledLibraries.Length == 0) + { + return false; + } + + // A series can live under more than one collection folder; opting in any one of them is + // enough. An item that belongs to no collection folder cannot be opted in at all. + return _libraryManager.GetCollectionFolders(item).Any(folder => + enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); + } + + /// <summary> + /// Removes every virtual episode this provider created (identified by being virtual and carrying + /// a TMDb id), plus any virtual season left without episodes as a result. Used when both import + /// options are disabled so turning the feature off cleans up its placeholders. + /// </summary> + private void RemoveAllVirtualItems(IProgress<double> progress, CancellationToken cancellationToken) + { + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + + var virtualEpisodes = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Episode], + IsVirtualItem = true, + HasTmdbId = true, + Recursive = true + }); + + for (var i = 0; i < virtualEpisodes.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Removing virtual episode {Name}: the TMDb missing episode provider is disabled", virtualEpisodes[i].Name); + _libraryManager.DeleteItem(virtualEpisodes[i], deleteOptions, false); + + progress.Report(95.0 * (i + 1) / virtualEpisodes.Count); + } + + // Remove virtual seasons that are now empty (mirrors the cleanup an ordinary series refresh does). + var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season], + IsVirtualItem = true, + HasTmdbId = true, + Recursive = true + }); + + foreach (var season in virtualSeasons.OfType<Season>()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (season.GetEpisodes().Count == 0) + { + _libraryManager.DeleteItem(season, deleteOptions, false); + } + } + + progress.Report(100); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 174f1546a7..d75ebae988 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -25,16 +25,27 @@ namespace MediaBrowser.Providers.Plugins.Tmdb { private const int CacheDurationInHours = 1; - private readonly IMemoryCache _memoryCache; + // Sized in TMDb records - see EstimateSize - rather than in responses, because the responses + // differ in weight by orders of magnitude. + private const int CacheSizeLimit = 100_000; + + private static readonly Dictionary<string, string> ThumbnailSizes = new Dictionary<string, string> + { + { "Primary", "w500" }, + { "Backdrop", "w780" }, + { "Thumb", "w780" }, + { "Logo", "w500" }, + }; + + private readonly MemoryCache _memoryCache; private readonly TMDbClient _tmDbClient; /// <summary> /// Initializes a new instance of the <see cref="TmdbClientManager"/> class. /// </summary> - /// <param name="memoryCache">An instance of <see cref="IMemoryCache"/>.</param> - public TmdbClientManager(IMemoryCache memoryCache) + public TmdbClientManager() { - _memoryCache = memoryCache; + _memoryCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = CacheSizeLimit }); var apiKey = Plugin.Instance.Configuration.TmdbApiKey; apiKey = string.IsNullOrEmpty(apiKey) ? TmdbUtils.ApiKey : apiKey; @@ -78,7 +89,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (movie is not null) { - _memoryCache.Set(key, movie, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, movie); } return movie; @@ -112,7 +123,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (collection is not null) { - _memoryCache.Set(key, collection, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, collection); } return collection; @@ -137,7 +148,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var extraMethods = TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; + var extraMethods = TvShowMethods.Credits | TvShowMethods.CreditsAggregate | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; if (!(Plugin.Instance?.Configuration.ExcludeTagsSeries).GetValueOrDefault()) { extraMethods |= TvShowMethods.Keywords; @@ -152,7 +163,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (series is not null) { - _memoryCache.Set(key, series, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, series); } return series; @@ -208,7 +219,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (group is not null) { - _memoryCache.Set(key, group, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, group); } return group; @@ -244,7 +255,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (season is not null) { - _memoryCache.Set(key, season, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, season); } return season; @@ -296,7 +307,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (episode is not null) { - _memoryCache.Set(key, episode, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, episode); } return episode; @@ -323,12 +334,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb person = await _tmDbClient.GetPersonAsync( personTmdbId, TmdbUtils.NormalizeLanguage(language, countryCode), - PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, + PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); if (person is not null) { - _memoryCache.Set(key, person, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, person); } return person; @@ -366,7 +377,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (result is not null) { - _memoryCache.Set(key, result, TimeSpan.FromHours(CacheDurationInHours)); + Cache(key, result); } return result; @@ -397,7 +408,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; @@ -425,7 +436,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; @@ -468,7 +479,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; @@ -498,26 +509,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (searchResults?.Results?.Count > 0) { - _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); + CacheSearch(key, searchResults); } return searchResults?.Results; } /// <summary> - /// Gets a single page of similar movies for a movie from the TMDb API. + /// Gets a single page of recommended movies for a movie from the TMDb API. /// </summary> /// <param name="tmdbId">The TMDb id of the movie.</param> /// <param name="page">The page number to fetch (1-based).</param> /// <param name="language">The language for results.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns> - public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + /// <returns>A tuple containing the list of recommended movies and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken) + .GetMovieRecommendationsAsync(tmdbId, language, page, cancellationToken) .ConfigureAwait(false); if (searchResults?.Results is null || searchResults.Results.Count == 0) @@ -529,19 +540,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Gets a single page of similar TV shows for a series from the TMDb API. + /// Gets a single page of recommended TV shows for a series from the TMDb API. /// </summary> /// <param name="tmdbId">The TMDb id of the TV show.</param> /// <param name="page">The page number to fetch (1-based).</param> /// <param name="language">The language for results.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns> - public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + /// <returns>A tuple containing the list of recommended TV shows and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) { await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken) + .GetTvShowRecommendationsAsync(tmdbId, language, page, cancellationToken) .ConfigureAwait(false); if (searchResults?.Results is null || searchResults.Results.Count == 0) @@ -565,8 +576,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return null; } - // Use "original" as default size if size is null or empty to prevent malformed URLs - var imageSize = string.IsNullOrEmpty(size) ? "original" : size; + // Use the original size as default if size is null or empty to prevent malformed URLs + var imageSize = string.IsNullOrEmpty(size) ? TmdbUtils.OriginalImageSize : size; return _tmDbClient.GetImageUrl(imageSize, path, true).ToString(); } @@ -592,6 +603,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Gets the absolute URL of an episode still. + /// </summary> + /// <param name="stillPath">The relative URL of the still.</param> + /// <returns>The absolute URL.</returns> + public string? GetStillUrl(string? stillPath) + { + return GetUrl(Plugin.Instance.Configuration.StillSize, stillPath); + } + + /// <summary> /// Converts poster <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s. /// </summary> /// <param name="images">The input images.</param> @@ -647,7 +668,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string? size, ImageType type, string requestLanguage) { // sizes provided are for original resolution, don't store them when downloading scaled images - var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); + var scaleImage = !TmdbUtils.IsOriginalImageSize(size); for (var i = 0; i < images.Count; i++) { @@ -665,6 +686,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb yield return new RemoteImageInfo { Url = GetUrl(size, image.FilePath), + ThumbnailUrl = GetUrl(ThumbnailSizes.GetValueOrDefault(type.ToString(), string.Empty), image.FilePath), CommunityRating = image.VoteAverage, VoteCount = image.VoteCount, Width = scaleImage ? null : image.Width, @@ -743,6 +765,84 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return _tmDbClient.Config; } + /// <summary> + /// Stores a response under the shared expiry, weighed by what it costs to keep. + /// </summary> + private void Cache<T>(string key, T value) + where T : class + => _memoryCache.Set( + key, + value, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours), + Size = EstimateSize(value) + }); + + /// <summary> + /// Stores a page of search results, whose weight is simply how many there are. + /// </summary> + private void CacheSearch<T>(string key, SearchContainer<T> results) + => _memoryCache.Set( + key, + results, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours), + Size = 1 + Count(results.Results) + }); + + private static long Count<T>(IReadOnlyCollection<T>? items) => items?.Count ?? 0; + + /// <summary> + /// Estimates what keeping a response costs, counting the sub-records that dominate it. + /// </summary> + private static long EstimateSize(object? value) => value switch + { + TvShow series => 1 + + Count(series.Credits?.Cast) + Count(series.Credits?.Crew) + + EstimateAggregateSize(series.AggregateCredits) + + Count(series.Seasons), + TvSeason season => 1 + + Count(season.Credits?.Cast) + Count(season.Credits?.Crew) + + Count(season.Episodes), + TvEpisode episode => 1 + + Count(episode.Credits?.Cast) + Count(episode.Credits?.Crew) + + Count(episode.Credits?.GuestStars), + Movie movie => 1 + Count(movie.Credits?.Cast) + Count(movie.Credits?.Crew), + Collection collection => 1 + Count(collection.Parts), + TvGroupCollection groups => 1 + Count(groups.Groups), + FindContainer found => 1 + + Count(found.MovieResults) + Count(found.TvResults) + + Count(found.PersonResults) + Count(found.TvEpisode) + Count(found.TvSeason), + _ => 1 + }; + + /// <summary> + /// Weighs aggregate credits, where each person carries one record per episode they worked on. + /// </summary> + private static long EstimateAggregateSize(CreditsAggregate? credits) + { + if (credits is null) + { + return 0; + } + + var size = Count(credits.Cast) + Count(credits.Crew); + + foreach (var cast in credits.Cast ?? []) + { + size += Count(cast.Roles); + } + + foreach (var crew in credits.Crew ?? []) + { + size += Count(crew.Jobs); + } + + return size; + } + /// <inheritdoc /> public void Dispose() { @@ -758,7 +858,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb { if (disposing) { - _memoryCache?.Dispose(); + _memoryCache.Dispose(); _tmDbClient?.Dispose(); } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 7e6b9beee9..f004251594 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -2,10 +2,15 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; +using TMDbLib.Objects.Search; +using TMDbLib.Objects.TvShows; +using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; namespace MediaBrowser.Providers.Plugins.Tmdb { @@ -30,6 +35,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public const string ApiKey = "4219e299c89411838049ab0dab19ebd5"; /// <summary> + /// The image size representing the unscaled image as served by TMDb. + /// </summary> + public const string OriginalImageSize = "original"; + + private const int TitleExactScore = 8; + private const int TitlePrefixScore = 4; + private const int YearExactScore = 2; + private const int YearAdjacentScore = 1; + + /// <summary> /// The crew types to keep. /// </summary> public static readonly string[] WantedCrewTypes = @@ -59,8 +74,47 @@ namespace MediaBrowser.Providers.Plugins.Tmdb "novel" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - [GeneratedRegex(@"[\W_-[·]]+")] - private static partial Regex NonWordRegex(); + /// <summary> + /// Everything that is not a letter, a number or a combining mark separates two search terms. The + /// interpunct is kept because TMDb uses it inside titles such as "WALL·E", where it matches better + /// than a space does. + /// </summary> + [GeneratedRegex(@"[^\p{L}\p{N}\p{M}·]+")] + private static partial Regex NonSearchTermRegex(); + + /// <summary> + /// As <see cref="NonSearchTermRegex"/>, but the interpunct is a separator too, so a "WALL-E" folder + /// and the "WALL·E" title TMDb returns compare equal. + /// </summary> + [GeneratedRegex(@"[^\p{L}\p{N}\p{M}]+")] + private static partial Regex NonComparableRegex(); + + /// <summary> + /// Gets the TMDb id of an item, if it has one TMDb can be queried with. + /// </summary> + /// <param name="instance">The item.</param> + /// <param name="tmdbId">The TMDb id.</param> + /// <returns><c>true</c> if the item has a usable TMDb id; otherwise, <c>false</c>.</returns> + public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId) + { + instance.TryGetProviderId(MetadataProvider.Tmdb, out var value); + + return TryParseTmdbId(value, out tmdbId); + } + + /// <summary> + /// Parses a TMDb id. + /// </summary> + /// <param name="value">The stored id.</param> + /// <param name="tmdbId">The TMDb id.</param> + /// <returns><c>true</c> if the value is a usable TMDb id; otherwise, <c>false</c>.</returns> + public static bool TryParseTmdbId(string? value, out int tmdbId) + { + // Another provider can have filed one of its own ids under the TMDb key, e.g. an IMDb person + // id. Reporting that as "no id" lets the caller fall back to a search and repair the id, + // instead of throwing on every refresh of the item. + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out tmdbId) && tmdbId > 0; + } /// <summary> /// Cleans the name according to TMDb requirements. @@ -70,7 +124,140 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public static string CleanName(string name) { // TMDb expects a space separated list of words make sure that is the case - return NonWordRegex().Replace(name, " "); + return NonSearchTermRegex().Replace(name, " ").Trim(); + } + + /// <summary> + /// Reduces a title to the form used to compare a local name against a TMDb search result. + /// </summary> + /// <param name="title">The title to normalize.</param> + /// <returns>The normalized title, or an empty string if there was nothing to normalize.</returns> + public static string NormalizeTitle(string? title) + { + return string.IsNullOrEmpty(title) + ? string.Empty + : NonComparableRegex().Replace(title, " ").Trim().ToLowerInvariant(); + } + + /// <summary> + /// Picks the movie search result that best matches the name and year an item was looked up by. + /// </summary> + /// <param name="results">The search results, in the order TMDb returned them.</param> + /// <param name="name">The parsed name of the local item.</param> + /// <param name="year">The year of the local item, or 0 if it is unknown.</param> + /// <returns>The best match, or <c>null</c> if there were no results.</returns> + public static SearchMovie? FindBestMatch(IReadOnlyList<SearchMovie>? results, string? name, int year) + { + return FindBestMatch( + results, + name, + year, + static movie => movie.Title, + static movie => movie.OriginalTitle, + static movie => movie.ReleaseDate); + } + + /// <summary> + /// Picks the series search result that best matches the name and year an item was looked up by. + /// </summary> + /// <param name="results">The search results, in the order TMDb returned them.</param> + /// <param name="name">The parsed name of the local item.</param> + /// <param name="year">The year of the local item, or 0 if it is unknown.</param> + /// <returns>The best match, or <c>null</c> if there were no results.</returns> + public static SearchTv? FindBestMatch(IReadOnlyList<SearchTv>? results, string? name, int year) + { + return FindBestMatch( + results, + name, + year, + static series => series.Name, + static series => series.OriginalName, + static series => series.FirstAirDate); + } + + /// <summary> + /// Picks the search result that best matches the name and year an item was looked up by. + /// </summary> + /// <remarks> + /// TMDb's year parameter only nudges relevance, it does not filter, so the first hit is regularly a + /// different film or show that happens to share the title - searching for "Mulan" with year 2020 + /// returns the 1998 film first. A title that matches outranks one that does not, and the year only + /// separates candidates that are otherwise equally good. When nothing matches at all TMDb's own + /// ordering is kept, so a name that needs fuzzy matching, such as "A Christmas No. 1" for + /// "A Christmas Number One", still resolves. + /// </remarks> + private static T? FindBestMatch<T>( + IReadOnlyList<T>? results, + string? name, + int year, + Func<T, string?> titleSelector, + Func<T, string?> originalTitleSelector, + Func<T, DateTime?> releaseDateSelector) + where T : class + { + if (results is null || results.Count == 0) + { + return null; + } + + var normalizedName = NormalizeTitle(name); + if (normalizedName.Length == 0) + { + return results[0]; + } + + var best = results[0]; + var bestScore = 0; + + foreach (var result in results) + { + var score = Math.Max( + ScoreTitle(normalizedName, titleSelector(result)), + ScoreTitle(normalizedName, originalTitleSelector(result))) + + ScoreYear(year, releaseDateSelector(result)?.Year); + + // Strictly greater, so ties keep the earlier, more relevant result. + if (score > bestScore) + { + bestScore = score; + best = result; + } + } + + return best; + } + + private static int ScoreTitle(string normalizedName, string? title) + { + var normalizedTitle = NormalizeTitle(title); + + if (string.Equals(normalizedName, normalizedTitle, StringComparison.Ordinal)) + { + return TitleExactScore; + } + + // Whole words only, otherwise "Wall" half matches "Wall Street". + return normalizedTitle.Length > normalizedName.Length + && normalizedTitle[normalizedName.Length] == ' ' + && normalizedTitle.StartsWith(normalizedName, StringComparison.Ordinal) + ? TitlePrefixScore + : 0; + } + + private static int ScoreYear(int year, int? resultYear) + { + if (year <= 0 || resultYear is not int candidateYear) + { + return 0; + } + + return Math.Abs(candidateYear - year) switch + { + 0 => YearExactScore, + // Regional release dates routinely straddle a new year. + 1 => YearAdjacentScore, + _ => 0 + }; } /// <summary> @@ -102,6 +289,100 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Maps an aggregated TMDb cast list, whose entries hold every role their member played. + /// </summary> + /// <param name="cast">The aggregated cast list, or <c>null</c>.</param> + /// <param name="config">The configuration deciding how much of the cast to keep.</param> + /// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param> + /// <returns>One credit per role played.</returns> + internal static IEnumerable<PersonInfo> MapAggregateCast( + IReadOnlyList<CastAggregate>? cast, + PluginConfiguration config, + Func<string?, string?> getProfileUrl) + { + if (cast is null) + { + yield break; + } + + var billed = cast + .Where(member => !string.IsNullOrWhiteSpace(member.Name)) + .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) + .OrderBy(member => member.Order) + .Take(config.MaxCastMembers); + + foreach (var member in billed) + { + // An actor playing several characters over the run gets one aggregated entry holding + // every role, so each of them becomes a credit of its own here. Their own billing puts + // the character they played the longest first. + var characters = member.Roles? + .Where(role => !string.IsNullOrWhiteSpace(role.Character)) + .OrderByDescending(role => role.EpisodeCount) + .Select(role => role.Character!.Trim()) + .ToArray(); + + if (characters is null || characters.Length == 0) + { + characters = [string.Empty]; + } + + foreach (var character in characters) + { + yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, character, getProfileUrl); + } + } + } + + /// <summary> + /// Maps a TMDb cast list whose entries hold the one character their member is credited for. + /// </summary> + /// <param name="cast">The cast list, or <c>null</c>.</param> + /// <param name="config">The configuration deciding how much of the cast to keep.</param> + /// <param name="getProfileUrl">Resolves a profile path into an absolute image url.</param> + /// <returns>One credit per cast entry.</returns> + internal static IEnumerable<PersonInfo> MapCast( + IReadOnlyList<Cast>? cast, + PluginConfiguration config, + Func<string?, string?> getProfileUrl) + { + if (cast is null) + { + yield break; + } + + var billed = cast + .Where(member => !string.IsNullOrWhiteSpace(member.Name)) + .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) + .OrderBy(member => member.Order) + .Take(config.MaxCastMembers); + + foreach (var member in billed) + { + yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, member.Character?.Trim() ?? string.Empty, getProfileUrl); + } + } + + private static PersonInfo CreateCredit(string name, int id, string? profilePath, int? order, string role, Func<string?, string?> getProfileUrl) + { + var personInfo = new PersonInfo + { + Name = name.Trim(), + Role = role, + Type = PersonKind.Actor, + SortOrder = order, + ImageUrl = getProfileUrl(profilePath) + }; + + if (id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, id.ToString(CultureInfo.InvariantCulture)); + } + + return personInfo; + } + + /// <summary> /// Determines whether a video is a trailer. /// </summary> /// <param name="video">The TMDb video.</param> @@ -210,6 +491,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Determines whether the configured image size fetches the image at its original resolution. + /// An unset size falls back to <see cref="OriginalImageSize"/>, see TmdbClientManager.GetUrl. + /// </summary> + /// <param name="size">The configured image size.</param> + /// <returns><c>true</c> if the original image is fetched; otherwise, <c>false</c>.</returns> + public static bool IsOriginalImageSize(string? size) + => string.IsNullOrEmpty(size) || string.Equals(size, OriginalImageSize, StringComparison.OrdinalIgnoreCase); + + /// <summary> /// Combines the metadata country code and the parental rating from the API into the value we store in our database. /// </summary> /// <param name="countryCode">The ISO 3166-1 country code of the rating country.</param> diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index c3458d4b2a..cd9dda21a0 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -33,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles private readonly ILibraryMonitor _monitor; private readonly IMediaSourceManager _mediaSourceManager; private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly HashSet<string> _allowedSubtitleFormats; private readonly ISubtitleProvider[] _subtitleProviders; @@ -43,6 +44,7 @@ namespace MediaBrowser.Providers.Subtitles ILibraryMonitor monitor, IMediaSourceManager mediaSourceManager, ILocalizationManager localizationManager, + IDirectoryService directoryService, IEnumerable<ISubtitleProvider> subtitleProviders, NamingOptions namingOptions) { @@ -51,6 +53,7 @@ namespace MediaBrowser.Providers.Subtitles _monitor = monitor; _mediaSourceManager = mediaSourceManager; _localization = localizationManager; + _directoryService = directoryService; _subtitleProviders = subtitleProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -281,6 +284,8 @@ namespace MediaBrowser.Providers.Subtitles await stream.CopyToAsync(fs).ConfigureAwait(false); } + _directoryService.Invalidate(path); + return; } else @@ -395,6 +400,8 @@ namespace MediaBrowser.Providers.Subtitles _monitor.ReportFileSystemChangeComplete(path, false); } + _directoryService.Invalidate(path); + return item.RefreshMetadata(CancellationToken.None); } diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index 596ca8d201..f662ac2367 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -44,6 +44,31 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo> { var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType); + // An episode cannot end before it starts. + if (item.IndexNumberEnd < item.IndexNumber) + { + Logger.LogWarning( + "Discarding episode range end {IndexNumberEnd} preceding episode number {IndexNumber} for {Path}", + item.IndexNumberEnd, + item.IndexNumber, + item.Path); + + item.IndexNumberEnd = null; + updatedType |= ItemUpdateType.MetadataImport; + } + else if (item.IndexNumberEnd.HasValue && !item.IndexNumber.HasValue) + { + // Without a first episode the end does not describe a range. Promoting it to the episode number + // would invent an identity the metadata never supplied, so drop the orphaned value instead. + Logger.LogWarning( + "Discarding episode range end {IndexNumberEnd} without an episode number for {Path}", + item.IndexNumberEnd, + item.Path); + + item.IndexNumberEnd = null; + updatedType |= ItemUpdateType.MetadataImport; + } + var seriesName = item.FindSeriesName(); if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal)) { diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs index 19b1bbe7b6..c3e5c791c0 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Xml; @@ -44,41 +46,60 @@ namespace MediaBrowser.XbmcMetadata.Parsers var xmlFile = File.ReadAllText(metadataFile); - var srch = "</episodedetails>"; - var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase); - - var xml = xmlFile; + // Split the nfo into its episodedetails blocks. + // This is needed because XBMC metadata uses multiple episodedetails blocks instead of an episodenumberend tag. + const string Srch = "</episodedetails>"; + var blocks = new List<string>(); + int index; + while ((index = xmlFile.IndexOf(Srch, StringComparison.OrdinalIgnoreCase)) != -1) + { + blocks.Add(xmlFile.Substring(0, index + Srch.Length)); + xmlFile = xmlFile.Substring(index + Srch.Length); + } - if (index != -1) + if (blocks.Count == 0) { - xml = xmlFile.Substring(0, index + srch.Length); - xmlFile = xmlFile.Substring(index + srch.Length); + // No closing tag, let the xml reader deal with whatever is in the file + blocks.Add(xmlFile); } // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions try { - // Extract episode details from the first episodedetails block - ReadEpisodeDetailsFromXml(item, xml, settings, cancellationToken); + if (blocks.Count == 1) + { + ReadEpisodeDetailsFromXml(item, blocks[0], settings, cancellationToken); + return; + } + + // The blocks are not guaranteed to be written in ascending episode order, so parse them all + // and sort them before merging. + var episodes = blocks + .Select(block => + { + var episode = new MetadataResult<Episode>() + { + Item = new Episode() + }; - // Extract the last episode number from nfo - // Retrieves all additional episodedetails blocks from the rest of the nfo and concatenates the name, originalTitle and overview tags with the first episode - // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag + ReadEpisodeDetailsFromXml(episode, block, settings, cancellationToken); + + return (Xml: block, Result: episode); + }) + .OrderBy(episode => episode.Result.Item.IndexNumber ?? int.MaxValue) + .ToList(); + + // Extract the details of the lowest numbered episode into the item that is returned to the caller + ReadEpisodeDetailsFromXml(item, episodes[0].Xml, settings, cancellationToken); + + // Concatenate the name, originalTitle and overview tags of the remaining episodes with the first one + // and take the highest episode number as the last episode of the file var name = new StringBuilder(item.Item.Name); var originalTitle = new StringBuilder(item.Item.OriginalTitle); var overview = new StringBuilder(item.Item.Overview); - while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1) + for (var i = 1; i < episodes.Count; i++) { - xml = xmlFile.Substring(0, index + srch.Length); - xmlFile = xmlFile.Substring(index + srch.Length); - - var additionalEpisode = new MetadataResult<Episode>() - { - Item = new Episode() - }; - - // Extract episode details from additional episodedetails block - ReadEpisodeDetailsFromXml(additionalEpisode, xml, settings, cancellationToken); + var additionalEpisode = episodes[i].Result; if (!string.IsNullOrEmpty(additionalEpisode.Item.Name)) { diff --git a/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs index b5ba2d24f2..af2557df78 100644 --- a/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/ArtistNfoSaver.cs @@ -82,7 +82,7 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("title", album.Name); } - if (album.ProductionYear.HasValue) + if (album.ProductionYear is not null) { writer.WriteElementString("year", album.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)); } diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 78907a5e68..5a9bbbef4f 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -544,7 +544,7 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("rating", item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)); } - if (item.ProductionYear.HasValue) + if (item.ProductionYear is not null) { writer.WriteElementString("year", item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)); } @@ -7,34 +7,16 @@ <img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/> <br/> <br/> -<a href="https://github.com/jellyfin/jellyfin"> -<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/> -</a> -<a href="https://github.com/jellyfin/jellyfin/releases"> -<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/> -</a> -<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"> -<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/> -</a> -<a href="https://hub.docker.com/r/jellyfin/jellyfin"> -<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/> -</a> +<a href="https://github.com/jellyfin/jellyfin"><img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/></a> +<a href="https://github.com/jellyfin/jellyfin/releases"><img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/></a> +<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"><img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/></a> +<a href="https://hub.docker.com/r/jellyfin/jellyfin"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/></a> <br/> -<a href="https://opencollective.com/jellyfin"> -<img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/> -</a> -<a href="https://features.jellyfin.org"> -<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/> -</a> -<a href="https://matrix.to/#/#jellyfinorg:matrix.org"> -<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/> -</a> -<a href="https://github.com/jellyfin/jellyfin/releases.atom"> -<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /> -</a> -<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"> -<img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /> -</a> +<a href="https://opencollective.com/jellyfin"><img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/></a> +<a href="https://features.jellyfin.org"><img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/></a> +<a href="https://matrix.to/#/#jellyfinorg:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/></a> +<a href="https://github.com/jellyfin/jellyfin/releases.atom"><img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /></a> +<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"><img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /></a> </p> --- diff --git a/SharedVersion.cs b/SharedVersion.cs index 1d4a368aa2..0b2ab6a013 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("12.0.0")] -[assembly: AssemblyFileVersion("12.0.0")] +[assembly: AssemblyVersion("13.0.0")] +[assembly: AssemblyFileVersion("13.0.0")] diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 88a2c684ff..2ba3faf5f7 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -1,18 +1,86 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.MatchCriteria; namespace Jellyfin.Database.Implementations; /// <summary> -/// Provides methods for querying item hierarchies using iterative traversal. +/// Provides methods for querying item hierarchies. /// Uses AncestorIds and LinkedChildren tables for parent-child traversal. /// </summary> public static class DescendantQueryHelper { /// <summary> + /// Gets the predicate identifying items that count toward played/total aggregation: + /// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes). + /// Shared by the per-item and batched count paths so they cannot diverge. + /// </summary> + public static Expression<Func<BaseItemEntity, bool>> IsCountableLeaf { get; } = + b => !b.IsFolder && !b.IsVirtualItem; + + /// <summary> + /// Gets the predicate identifying the items that stand on their own in a library. An alternate + /// version is a second file for the item that links it rather than an item beside it, and an owned + /// item belongs to its owner unless it is an extra (a trailer and the like, which carries both an + /// owner and an extra type). Nothing here turns on who is asking, so a count that applies it + /// answers the same with a user and without one. + /// </summary> + public static Expression<Func<BaseItemEntity, bool>> IsDistinctLibraryItem { get; } = + b => !b.PrimaryVersionId.HasValue && (!b.OwnerId.HasValue || b.ExtraType != null); + + /// <summary> + /// Builds the predicate identifying the items a user has played, counting a multi-version item as + /// played when any of its alternate versions is. Mirrors the aggregation + /// <c>VersionResumeData.ApplyTo</c> performs on the played flag a single item reports, so that a + /// folder's unplayed count cannot disagree with the watched state its members render with. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The predicate matching the items that user has played.</returns> + public static Expression<Func<BaseItemEntity, bool>> IsPlayedBy(Guid userId) => + b => b.UserData!.Any(u => u.UserId.Equals(userId) && u.Played) + || b.LinkedChildEntities!.Any(lc => + (lc.ChildType == LinkedChildType.LocalAlternateVersion || lc.ChildType == LinkedChildType.LinkedAlternateVersion) + && lc.Child!.UserData!.Any(u => u.UserId.Equals(userId) && u.Played)); + + /// <summary> + /// Builds the projection pairing an item's id with <see cref="IsPlayedBy"/> evaluated on that same + /// row. A caller that needs the flag alongside the id composes it rather than testing membership of + /// the played set: as a sub-select the set is unbounded by whatever the caller joins it to, so the + /// database builds it from the whole table once per place it appears. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The projection of each item onto its id and that user's played state.</returns> + public static Expression<Func<BaseItemEntity, LeafPlayedState>> PlayedStateBy(Guid userId) + { + var played = IsPlayedBy(userId); + var item = played.Parameters[0]; + + // Named members, as the compiler emits for an anonymous type: without them the query provider + // cannot read a later `x.Id` back to the column it was built from and gives up translating. + return Expression.Lambda<Func<BaseItemEntity, LeafPlayedState>>( + Expression.New( + typeof(LeafPlayedState).GetConstructor([typeof(Guid), typeof(bool)])!, + [Expression.Property(item, nameof(BaseItemEntity.Id)), played.Body], + [typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Id))!, typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Played))!]), + item); + } + + /// <summary> + /// Builds the negation of <see cref="IsPlayedBy"/>, so a caller filtering for unplayed items reads + /// the same definition of played as one filtering for played items. + /// </summary> + /// <param name="userId">The id of the user whose played state to test.</param> + /// <returns>The predicate matching the items that user has not played.</returns> + public static Expression<Func<BaseItemEntity, bool>> IsUnplayedBy(Guid userId) + { + var played = IsPlayedBy(userId); + return Expression.Lambda<Func<BaseItemEntity, bool>>(Expression.Not(played.Body), played.Parameters); + } + + /// <summary> /// Gets a queryable of all descendant IDs for a parent item. /// Traverses AncestorIds and LinkedChildren to find all descendants. /// </summary> @@ -23,11 +91,36 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); - var descendants = TraverseHierarchyDown(context, [parentId]); + return AllDescendants(context, [parentId]) + .Where(e => !e.Equals(parentId)) + .Distinct(); + } + + /// <summary> + /// Gets all descendant IDs for multiple parent items in a single traversal. + /// Traverses AncestorIds and LinkedChildren, like <see cref="GetAllDescendantIds"/>, but resolves + /// the roots once for all seeds instead of once per seed. + /// </summary> + /// <param name="context">Database context.</param> + /// <param name="parentIds">Parent item IDs.</param> + /// <returns>Set of all descendant item IDs (excluding the parent IDs themselves).</returns> + public static HashSet<Guid> GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList<Guid> parentIds) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(parentIds); + + if (parentIds.Count == 0) + { + return []; + } - descendants.Remove(parentId); + var descendants = AllDescendants(context, parentIds) + .Distinct() + .ToHashSet(); - return descendants.AsQueryable(); + descendants.ExceptWith(parentIds); + + return descendants; } /// <summary> @@ -42,11 +135,9 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); - var descendants = TraverseHierarchyDownOwned(context, [parentId]); - - descendants.Remove(parentId); - - return descendants.AsQueryable(); + return ClosureDescendants(context, [parentId]) + .Where(e => !e.Equals(parentId)) + .Distinct(); } /// <summary> @@ -67,11 +158,11 @@ public static class DescendantQueryHelper return []; } - var seedSet = new HashSet<Guid>(parentIds); - var descendants = TraverseHierarchyDownOwned(context, seedSet); + var descendants = ClosureDescendants(context, parentIds) + .Distinct() + .ToHashSet(); - // Remove the seed IDs — callers want only descendants - descendants.ExceptWith(seedSet); + descendants.ExceptWith(parentIds); return descendants; } @@ -87,28 +178,106 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); - var matchingItemIds = criteria switch + + // Both sides of a version group can hold a folder a caller would see as matching: the + // alternate carries its own AncestorIds rows and may sit in a different library than the + // primary it is reported against, and the primary is the item that becomes visible. + var reportedItemIds = MatchingMediaOwnerIds(context, criteria) + .Concat(GetPrimaryVersionIdsMatching(context, criteria)) + .Distinct(); + + // One hop up the closure covers every ancestor level. + var hierarchyAncestors = context.AncestorIds + .Where(e => reportedItemIds.Contains(e.ItemId)) + .Select(e => e.ParentItemId); + + var linkParents = ResolveLinkParents(context, reportedItemIds, hierarchyAncestors); + + // Read back as a sub-select so the result stays composable. Off the primary key, which is one + // row per id: LinkedChildren would yield one row per link and lean on the outer Distinct. + var linkedParents = context.BaseItems + .WhereOneOrMany(linkParents, e => e.Id) + .Select(e => e.Id); + + var linkedParentAncestors = context.AncestorIds + .WhereOneOrMany(linkParents, e => e.ItemId) + .Select(e => e.ParentItemId); + + // The chain an item carries stops at its collection folders, so this hop crosses that seam to + // the UserRootFolder above them. One statement for both sides beats a sub-select per side. + var seamAncestors = context.AncestorIds + .Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId)) + .Select(e => e.ParentItemId); + + return hierarchyAncestors + .Concat(linkedParents) + .Concat(linkedParentAncestors) + .Concat(seamAncestors) + .Distinct(); + } + + /// <summary> + /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the + /// criteria. + /// </summary> + /// <param name="context">Database context.</param> + /// <param name="criteria">The matching criteria to apply.</param> + /// <returns>Queryable of primary version item IDs.</returns> + /// <remarks> + /// For callers that already test an item's own media with their own indexed predicate: this covers + /// exactly what such a predicate misses, and the filtered PrimaryVersionId index keeps it to the few + /// items that have versions at all. + /// </remarks> + public static IQueryable<Guid> GetPrimaryVersionIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(criteria); + + // Anchored on the alternates rather than on the matches: "has a primary version" is served by + // the partial PrimaryVersionId index, which holds only the few items that are second files, so + // this costs a seek each into the stream index instead of a second pass over every stream row. + var alternates = context.BaseItems.Where(v => v.PrimaryVersionId.HasValue); + + if (criteria is HasChapterImages) { - HasSubtitles => context.MediaStreamInfos - .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle) - .Select(ms => ms.ItemId) - .Distinct() - .ToHashSet(), - HasChapterImages => context.Chapters + return alternates + .Where(v => context.Chapters.Any(c => c.ItemId.Equals(v.Id) && c.ImagePath != null)) + .Select(v => v.PrimaryVersionId!.Value); + } + + var matchingStreams = MatchingMediaStreams(context, criteria); + + return alternates + .Where(v => matchingStreams.Any(ms => ms.ItemId.Equals(v.Id))) + .Select(v => v.PrimaryVersionId!.Value); + } + + // The ids of the items whose own media matches. Kept to the stream and chapter tables so their + // covering indexes answer this outright: projecting the BaseItems navigation instead would add a + // primary-key lookup per stream row rather than one per matching item, and the leading key of both + // indexes leaves the ids already grouped, so the Distinct costs no sort. + private static IQueryable<Guid> MatchingMediaOwnerIds(JellyfinDbContext context, FolderMatchCriteria criteria) + => criteria is HasChapterImages + ? context.Chapters .Where(c => c.ImagePath != null) .Select(c => c.ItemId) .Distinct() - .ToHashSet(), - HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m), + : MatchingMediaStreams(context, criteria) + .Select(ms => ms.ItemId) + .Distinct(); + + // The stream rows a criteria matches. One definition, so the owner projection and the alternate + // projection cannot drift apart despite reading it from opposite ends. + private static IQueryable<MediaStreamInfo> MatchingMediaStreams(JellyfinDbContext context, FolderMatchCriteria criteria) + => criteria switch + { + HasSubtitles => context.MediaStreamInfos + .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle), + HasMediaStreamType m => GetMatchingMediaStreams(context, m), _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") }; - var ancestors = TraverseHierarchyUp(context, matchingItemIds); - - return ancestors.AsQueryable(); - } - - private static HashSet<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) + private static IQueryable<MediaStreamInfo> GetMatchingMediaStreams(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos .Where(ms => ms.StreamType == criteria.StreamType @@ -121,130 +290,140 @@ public static class DescendantQueryHelper query = query.Where(ms => ms.IsExternal == isExternal); } - return query.Select(ms => ms.ItemId).Distinct().ToHashSet(); + return query; } - /// <summary> - /// Traverses DOWN the hierarchy from parent folders to find all descendants. - /// </summary> - private static HashSet<Guid> TraverseHierarchyDown(JellyfinDbContext context, ICollection<Guid> startIds) + private static IQueryable<Guid> AllDescendants(JellyfinDbContext context, IReadOnlyList<Guid> parentIds) { - var visited = new HashSet<Guid>(startIds); - var folderStack = new HashSet<Guid>(startIds); - - while (folderStack.Count != 0) - { - var currentFolders = folderStack.ToArray(); - folderStack.Clear(); - - var directChildren = context.AncestorIds - .WhereOneOrMany(currentFolders, e => e.ParentItemId) - .Select(e => e.ItemId) - .ToArray(); - - var linkedChildren = context.LinkedChildren - .WhereOneOrMany(currentFolders, e => e.ParentId) - .Select(e => e.ChildId) - .ToArray(); + var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentIds); - var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray(); + var linkedDescendants = context.LinkedChildren + .WhereOneOrMany(linkRoots, e => e.ParentId) + .Select(e => e.ChildId); - if (allChildren.Length == 0) - { - break; - } + return ClosureDescendants(context, closureRoots) + .Concat(linkedDescendants); + } - var childFolders = context.BaseItems - .WhereOneOrMany(allChildren, e => e.Id) - .Where(e => e.IsFolder) - .Select(e => e.Id) - .ToHashSet(); + private static IQueryable<Guid> ClosureDescendants(JellyfinDbContext context, IReadOnlyList<Guid> roots) + { + var direct = context.AncestorIds + .WhereOneOrMany(roots, e => e.ParentItemId) + .Select(e => e.ItemId); - foreach (var childId in allChildren) - { - if (visited.Add(childId) && childFolders.Contains(childId)) - { - folderStack.Add(childId); - } - } - } + // An item carries its own chain plus its collection folders, never the UserRootFolder. + var indirect = context.AncestorIds + .Where(e => direct.Contains(e.ParentItemId)) + .Select(e => e.ItemId); - return visited; + return direct.Concat(indirect); } - /// <summary> - /// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren. - /// </summary> - private static HashSet<Guid> TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection<Guid> startIds) + // Resolves the folders whose linked children lead, at any depth, to a matching item. + private static List<Guid> ResolveLinkParents(JellyfinDbContext context, IQueryable<Guid> matchingItemIds, IQueryable<Guid> ancestorsOfMatches) { - var visited = new HashSet<Guid>(startIds); - var folderStack = new HashSet<Guid>(startIds); - - while (folderStack.Count != 0) + // An alternate version is a second file for the item that links it, not a child of it, so that + // edge is not walked. It is also the one link a non-folder owns, and there is one per remuxed + // movie: walking it would swell this list from the BoxSet and Playlist count to the item count, + // and the list is bound into every statement the returned queryable is embedded in. + var containerLinks = context.LinkedChildren + .Where(e => e.ChildType != LinkedChildType.LocalAlternateVersion + && e.ChildType != LinkedChildType.LinkedAlternateVersion); + + // A link sits above the closure and above another link alike, so the hop repeats until nothing + // new turns up. + var resolved = containerLinks + .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId)) + .Select(e => e.ParentId) + .Distinct() + .ToHashSet(); + + var frontier = resolved.ToList(); + + while (frontier.Count != 0) { - var currentFolders = folderStack.ToArray(); - folderStack.Clear(); + var containingFolders = context.AncestorIds + .WhereOneOrMany(frontier, e => e.ItemId) + .Select(e => e.ParentItemId); - var directChildren = context.AncestorIds - .WhereOneOrMany(currentFolders, e => e.ParentItemId) - .Select(e => e.ItemId) - .ToArray(); + var directLinkParents = containerLinks + .WhereOneOrMany(frontier, e => e.ChildId) + .Select(e => e.ParentId); - if (directChildren.Length == 0) - { - break; - } + var indirectLinkParents = containerLinks + .Where(e => containingFolders.Contains(e.ChildId)) + .Select(e => e.ParentId); - var childFolders = context.BaseItems - .WhereOneOrMany(directChildren, e => e.Id) - .Where(e => e.IsFolder) - .Select(e => e.Id) - .ToHashSet(); + var next = directLinkParents + .Concat(indirectLinkParents) + .Distinct() + .ToArray(); - foreach (var childId in directChildren) + frontier = []; + foreach (var id in next) { - if (visited.Add(childId) && childFolders.Contains(childId)) + // Cyclic links terminate on the resolved set. + if (resolved.Add(id)) { - folderStack.Add(childId); + frontier.Add(id); } } } - return visited; + return [.. resolved]; } - /// <summary> - /// Traverses UP the hierarchy from items to find all ancestor folders. - /// </summary> - private static HashSet<Guid> TraverseHierarchyUp(JellyfinDbContext context, ICollection<Guid> startIds) + // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure, + // and those contributing their linked children. + private static (List<Guid> ClosureRoots, List<Guid> LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, IReadOnlyList<Guid> parentIds) { - var ancestors = new HashSet<Guid>(); - var itemStack = new HashSet<Guid>(startIds); + var visited = new HashSet<Guid>(parentIds); + var closureRoots = visited.ToList(); + var linkRoots = visited.ToList(); + var frontier = visited.ToList(); - while (itemStack.Count != 0) + while (frontier.Count != 0) { - var currentItems = itemStack.ToArray(); - itemStack.Clear(); + var closureIds = ClosureDescendants(context, frontier); - var ancestorParents = context.AncestorIds - .WhereOneOrMany(currentItems, e => e.ItemId) - .Select(e => e.ParentItemId) - .ToArray(); + var linkedIds = context.LinkedChildren + .WhereOneOrMany(frontier, e => e.ParentId) + .Select(e => e.ChildId); - var linkedParents = context.LinkedChildren - .WhereOneOrMany(currentItems, e => e.ChildId) + var linkedFolders = context.BaseItems + .Where(e => e.IsFolder && linkedIds.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet(); + + // Folders whose own links have to be followed. Driven off LinkedChildren because owning a + // link is the rare property, so the folder check only reaches rows that can qualify. That + // check stays: a non-folder owns links too (a movie and its alternate versions). + var linkOwners = context.LinkedChildren + .Where(e => (closureIds.Contains(e.ParentId) || linkedIds.Contains(e.ParentId)) + && e.Parent!.IsFolder) .Select(e => e.ParentId) + .Distinct() .ToArray(); - foreach (var parentId in ancestorParents.Concat(linkedParents)) + frontier = []; + foreach (var id in linkOwners.Concat(linkedFolders)) { - if (ancestors.Add(parentId)) + if (!visited.Add(id)) + { + continue; + } + + frontier.Add(id); + linkRoots.Add(id); + + // Only a folder reached through a link adds a closure the roots so far do not cover. + if (linkedFolders.Contains(id)) { - itemStack.Add(parentId); + closureRoots.Add(id); } } } - return ancestors; + return (closureRoots, linkRoots); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs index 7361775711..be315f1b2c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs @@ -25,7 +25,7 @@ public class LinkedChildEntity /// <summary> /// Gets or sets the sort order. /// </summary> - public int? SortOrder { get; set; } + public int SortOrder { get; set; } /// <summary> /// Gets or sets the parent item navigation property. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs index 84b86574cc..eae02dda1c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs @@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities /// <summary> /// Gets or sets the id of the associated user. /// </summary> - public Guid? UserId { get; set; } + public Guid UserId { get; set; } /// <summary> /// Gets the type of this permission. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs index c02ea7375a..9bd159f2cf 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities /// <summary> /// Gets or sets the id of the associated user. /// </summary> - public Guid? UserId { get; set; } + public Guid UserId { get; set; } /// <summary> /// Gets the type of this preference. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index b10e210e5d..bf6568d10a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Interfaces; @@ -326,7 +325,6 @@ namespace Jellyfin.Database.Implementations.Entities /// <summary> /// Gets the list of permissions this user has. /// </summary> - [ForeignKey("Permission_Permissions_Guid")] public virtual ICollection<Permission> Permissions { get; private set; } /* @@ -339,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities /// <summary> /// Gets the list of preferences this user has. /// </summary> - [ForeignKey("Preference_Preferences_Guid")] public virtual ICollection<Preference> Preferences { get; private set; } /// <inheritdoc/> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs index 27dbeaba6a..0a72287ba9 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -37,14 +37,18 @@ public interface IJellyfinDatabaseProvider void ConfigureConventions(ModelConfigurationBuilder configurationBuilder); /// <summary> - /// If supported this should run any periodic maintaince tasks. + /// If supported this should run any periodic maintaince tasks, reclaiming unused space and refreshing the query + /// planner statistics. Also used after migrations have modified the database. /// </summary> /// <param name="cancellationToken">The token to abort the operation.</param> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> Task RunScheduledOptimisation(CancellationToken cancellationToken); /// <summary> - /// If supported this should perform any actions that are required on stopping the jellyfin server. + /// If supported this should perform any actions that are required on stopping the jellyfin server. This runs + /// against a deadline imposed by the service manager, so unlike + /// <see cref="RunScheduledOptimisation(CancellationToken)"/> it should only do work whose cost does not grow with + /// the size of the database. /// </summary> /// <param name="cancellationToken">The token that will be used to abort the operation.</param> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> 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 0b29a71cbd..887ba114fc 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj @@ -13,7 +13,6 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Database.Implementations</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index 1af7460540..0dfce732ce 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -14,11 +14,17 @@ namespace Jellyfin.Database.Implementations; /// <summary> /// Contains a number of query related extensions. /// </summary> +/// <remarks> +/// Every helper here binds its values through <see cref="EF.Parameter{T}(T)"/>. Values embedded as bare +/// constants are inlined into the SQL as literals, which gives each distinct value its own entry in EF's +/// compiled query cache and its own statement for the database to plan. +/// </remarks> public static class JellyfinQueryHelperExtensions { private static readonly MethodInfo _containsMethodGenericCache = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static).First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2); private static readonly MethodInfo _efParameterInstruction = typeof(EF).GetMethod(nameof(EF.Parameter), BindingFlags.Public | BindingFlags.Static)!; private static readonly ConcurrentDictionary<Type, MethodInfo> _containsQueryCache = new(); + private static readonly ConcurrentDictionary<Type, MethodInfo> _efParameterCache = new(); /// <summary> /// Builds an optimised query checking one property against a list of values while maintaining an optimal query. @@ -26,15 +32,69 @@ public static class JellyfinQueryHelperExtensions /// <typeparam name="TEntity">The entity.</typeparam> /// <typeparam name="TProperty">The property type to compare.</typeparam> /// <param name="query">The source query.</param> - /// <param name="oneOf">The list of items to check.</param> + /// <param name="oneOf">The list of items to check. An empty list matches nothing.</param> /// <param name="property">Property expression.</param> /// <returns>A Query.</returns> - public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) + public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) { return query.Where(OneOrManyExpressionBuilder(oneOf, property)); } /// <summary> + /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query. + /// </summary> + /// <typeparam name="TEntity">The entity.</typeparam> + /// <typeparam name="TProperty">The property type to compare.</typeparam> + /// <param name="oneOf">The list of items to check. An empty list matches nothing.</param> + /// <param name="property">Property expression.</param> + /// <returns>A Query.</returns> + public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) + { + ArgumentNullException.ThrowIfNull(oneOf); + ArgumentNullException.ThrowIfNull(property); + + var parameter = Expression.Parameter(typeof(TEntity), "item"); + property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter); + + if (oneOf.Count == 0) + { + // Fail closed, and without asking the database to unpack an empty collection to prove it. + return Expression.Lambda<Func<TEntity, bool>>(Expression.Constant(false), parameter); + } + + if (oneOf.Count == 1) + { + var value = Expression.Call( + null, + EfParameterFor(typeof(TProperty)), + Expression.Constant(oneOf[0], typeof(TProperty))); + + return Expression.Lambda<Func<TEntity, bool>>( + typeof(TProperty).IsValueType + ? Expression.Equal(property.Body, value) + : Expression.ReferenceEqual(property.Body, value), + parameter); + } + + var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); + + // Binding the whole collection as one parameter keeps the statement identical for any element + // count, instead of emitting one placeholder per element. + return Expression.Lambda<Func<TEntity, bool>>( + Expression.Call( + null, + containsMethodInfo, + Expression.Call(null, EfParameterFor(oneOf.GetType()), Expression.Constant(oneOf)), + property.Body), + parameter); + } + + private static MethodInfo EfParameterFor(Type type) + { + return _efParameterCache.GetOrAdd(type, static (key) => _efParameterInstruction.MakeGenericMethod(key)); + } + + /// <summary> /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup. /// </summary> /// <param name="baseQuery">The source query.</param> @@ -47,191 +107,161 @@ public static class JellyfinQueryHelperExtensions this IQueryable<BaseItemEntity> baseQuery, JellyfinDbContext context, ItemValueType itemValueType, - IList<Guid> referenceIds, + IReadOnlyList<Guid> referenceIds, bool invert = false) { - return baseQuery.Where(ReferencedItemFilterExpressionBuilder(context, itemValueType, referenceIds, invert)); + return baseQuery.WhereReferencedItem(context, [itemValueType], referenceIds, invert); } /// <summary> - /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup. + /// Builds a query that checks referenced ItemValues of any of the given types for a cross BaseItem lookup. /// </summary> /// <param name="baseQuery">The source query.</param> /// <param name="context">The database context.</param> - /// <param name="itemValueTypes">The type of item value to reference.</param> + /// <param name="itemValueTypes">The types of item value to reference.</param> /// <param name="referenceIds">The list of BaseItem ids to check matches.</param> /// <param name="invert">If set an exclusion check is performed instead.</param> /// <returns>A Query.</returns> - public static IQueryable<BaseItemEntity> WhereReferencedItemMultipleTypes( + /// <remarks> + /// Matching is on CleanName alone. Genre/artist/album etc items do not set an ItemValue of their own + /// type, so the referenced item's Type is never consulted and ids whose names clean to the same value + /// are interchangeable across types. + /// </remarks> + public static IQueryable<BaseItemEntity> WhereReferencedItem( this IQueryable<BaseItemEntity> baseQuery, JellyfinDbContext context, - IList<ItemValueType> itemValueTypes, - IList<Guid> referenceIds, + IReadOnlyList<ItemValueType> itemValueTypes, + IReadOnlyList<Guid> referenceIds, bool invert = false) { - var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id); - var typeFilter = OneOrManyExpressionBuilder<ItemValue, ItemValueType>(itemValueTypes, iv => iv.Type); - - return baseQuery.Where(item => - context.ItemValues - .Where(typeFilter) - .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map }) - .Any(val => - context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue) - && val.map.ItemId == item.Id) == EF.Constant(!invert)); - } + ArgumentNullException.ThrowIfNull(context); - /// <summary> - /// Builds a query expression that checks referenced ItemValues for a cross BaseItem lookup. - /// </summary> - /// <param name="context">The database context.</param> - /// <param name="itemValueType">The type of item value to reference.</param> - /// <param name="referenceIds">The list of BaseItem ids to check matches.</param> - /// <param name="invert">If set an exclusion check is performed instead.</param> - /// <returns>A Query.</returns> - public static Expression<Func<BaseItemEntity, bool>> ReferencedItemFilterExpressionBuilder( - this JellyfinDbContext context, - ItemValueType itemValueType, - IList<Guid> referenceIds, - bool invert = false) - { - // Well genre/artist/album etc items do not actually set the ItemValue of thier specitic types so we cannot match it that way. - /* - "(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@GenreIds and Type=2)))" - */ - - var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id); - - return item => - context.ItemValues - .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (item, map) => new { item, map }) - .Any(val => - val.item.Type == itemValueType - && context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.item.CleanValue) - && val.map.ItemId == item.Id) == EF.Constant(!invert); + // Flat sub-selects rather than a correlated .Any(...Any(...)). + var referencedCleanValues = context.BaseItems + .Where(OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, e => e.Id)) + .Select(e => e.CleanName); + + var matchingItemIds = context.ItemValuesMap + .Where(OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type)) + .Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue)) + .Select(m => m.ItemId); + + return invert + ? baseQuery.Where(e => !matchingItemIds.Contains(e.Id)) + : baseQuery.Where(e => matchingItemIds.Contains(e.Id)); } /// <summary> - /// Filters items that match any of the specified (provider name, value) pairs. + /// Filters items that have any of the specified providers, optionally restricted to given values. /// </summary> /// <param name="baseQuery">The source query.</param> - /// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param> + /// <param name="providerIds">Dictionary mapping provider names to values to match. An empty value array matches any value for that provider.</param> /// <returns>A filtered query.</returns> public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds( this IQueryable<BaseItemEntity> baseQuery, IReadOnlyDictionary<string, string[]> providerIds) { - var providerKeys = providerIds - .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) - .ToList(); - - if (providerKeys.Count == 0) - { - return baseQuery; - } - - return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + return baseQuery.WhereProviderMatch(Flatten(providerIds), false); } /// <summary> - /// Filters items that have any of the specified providers. Empty/null values match any value for that provider. + /// Filters items that have any of the specified providers, optionally restricted to a given value. /// </summary> /// <param name="baseQuery">The source query.</param> - /// <param name="providerIds">Dictionary mapping provider names to optional values.</param> + /// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value matches any value for that provider.</param> /// <returns>A filtered query.</returns> public static IQueryable<BaseItemEntity> WhereHasAnyProviderId( this IQueryable<BaseItemEntity> baseQuery, IReadOnlyDictionary<string, string> providerIds) { - var existenceOnly = providerIds - .Where(e => string.IsNullOrEmpty(e.Value)) - .Select(e => e.Key) - .ToList(); - - var specificValues = providerIds - .Where(e => !string.IsNullOrEmpty(e.Value)) - .Select(e => $"{e.Key}:{e.Value}") - .ToList(); - - if (existenceOnly.Count == 0 && specificValues.Count == 0) - { - return baseQuery; - } - - if (existenceOnly.Count == 0) - { - return baseQuery.Where(e => e.Provider!.Any(p => - specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); - } - - if (specificValues.Count == 0) - { - return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId))); - } - - // Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries. - return baseQuery.Where(e => e.Provider!.Any(p => - existenceOnly.Contains(p.ProviderId) || - specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); + return baseQuery.WhereProviderMatch(providerIds, false); } /// <summary> - /// Excludes items that match any of the specified (provider name, value) pairs. + /// Excludes items that have any of the specified providers, optionally restricted to a given value. /// </summary> /// <param name="baseQuery">The source query.</param> - /// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param> + /// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value excludes any value for that provider.</param> /// <returns>A filtered query.</returns> public static IQueryable<BaseItemEntity> WhereExcludeProviderIds( this IQueryable<BaseItemEntity> baseQuery, IReadOnlyDictionary<string, string> providerIds) { - var excludeKeys = providerIds - .Select(e => $"{e.Key}:{e.Value}") - .ToList(); + return baseQuery.WhereProviderMatch(providerIds, true); + } + + private static IEnumerable<KeyValuePair<string, string>> Flatten(IReadOnlyDictionary<string, string[]> providerIds) + { + ArgumentNullException.ThrowIfNull(providerIds); - if (excludeKeys.Count == 0) + foreach (var (provider, values) in providerIds) { - return baseQuery; - } + if (values is null || values.Length == 0) + { + yield return new KeyValuePair<string, string>(provider, string.Empty); + continue; + } - return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + foreach (var value in values) + { + yield return new KeyValuePair<string, string>(provider, value); + } + } } /// <summary> - /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query. + /// Matches items against a set of (provider, value) pairs, where an empty value means any value for + /// that provider. Emits a single EXISTS over the provider collection with the predicates OR'd, rather + /// than one subquery per predicate group. /// </summary> - /// <typeparam name="TEntity">The entity.</typeparam> - /// <typeparam name="TProperty">The property type to compare.</typeparam> - /// <param name="oneOf">The list of items to check.</param> - /// <param name="property">Property expression.</param> - /// <returns>A Query.</returns> - public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property) + private static IQueryable<BaseItemEntity> WhereProviderMatch( + this IQueryable<BaseItemEntity> baseQuery, + IEnumerable<KeyValuePair<string, string>> providerIds, + bool invert) { - var parameter = Expression.Parameter(typeof(TEntity), "item"); - property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter); - if (oneOf.Count == 1) + ArgumentNullException.ThrowIfNull(providerIds); + + var existenceOnly = new List<string>(); + var specificValues = new List<string>(); + foreach (var (provider, value) in providerIds) { - var value = oneOf[0]; - if (typeof(TProperty).IsValueType) + if (string.IsNullOrEmpty(value)) { - return Expression.Lambda<Func<TEntity, bool>>(Expression.Equal(property.Body, Expression.Constant(value)), parameter); + existenceOnly.Add(provider); } else { - return Expression.Lambda<Func<TEntity, bool>>(Expression.ReferenceEqual(property.Body, Expression.Constant(value)), parameter); + specificValues.Add(provider + ":" + value); } } - var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); - - // Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a - // parameterized array lookup by ~5-10% up to ~32 elements. - if (oneOf.Count <= 32) + if (existenceOnly.Count == 0 && specificValues.Count == 0) { - return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter); + return baseQuery; } - return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter); + var predicate = ProviderPredicate(existenceOnly, specificValues); + + // NOT EXISTS rather than NOT IN: the latter yields no rows at all if the subquery can produce NULL. + return invert + ? baseQuery.Where(e => !e.Provider!.AsQueryable().Any(predicate)) + : baseQuery.Where(e => e.Provider!.AsQueryable().Any(predicate)); + } + + private static Expression<Func<BaseItemProvider, bool>> ProviderPredicate( + IReadOnlyList<string> existenceOnly, + IReadOnlyList<string> specificValues) + { + var byProvider = existenceOnly.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId); + var byPair = specificValues.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId + ":" + p.ProviderValue); + + // Both builders mint their own parameter; rebind so the two bodies can share one lambda. + var parameter = byProvider.Parameters[0]; + var reboundPair = ParameterReplacer.Replace<Func<BaseItemProvider, bool>, Func<BaseItemProvider, bool>>(byPair, byPair.Parameters[0], parameter); + + return Expression.Lambda<Func<BaseItemProvider, bool>>( + Expression.OrElse(byProvider.Body, reboundPair.Body), + parameter); } internal static class ParameterReplacer diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs new file mode 100644 index 0000000000..0846013c45 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs @@ -0,0 +1,10 @@ +using System; + +namespace Jellyfin.Database.Implementations; + +/// <summary> +/// An item's id paired with whether a given user has played it. +/// </summary> +/// <param name="Id">The id of the item.</param> +/// <param name="Played">Whether the user has played the item.</param> +public readonly record struct LeafPlayedState(Guid Id, bool Played); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index 76ffa5a9ea..29a073ff74 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -88,13 +88,13 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior /// <inheritdoc/> public void OnSaveChanges(JellyfinDbContext context, Action saveChanges) { - _writePolicy.ExecuteAndCapture(saveChanges); + _writePolicy.Execute(saveChanges); } /// <inheritdoc/> public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges) { - await _writeAsyncPolicy.ExecuteAndCaptureAsync(saveChanges).ConfigureAwait(false); + await _writeAsyncPolicy.ExecuteAsync(saveChanges).ConfigureAwait(false); } private sealed class TransactionLockingInterceptor : DbTransactionInterceptor diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs index 404292e8eb..e7a7d5a53f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs @@ -17,6 +17,13 @@ namespace Jellyfin.Database.Implementations.Locking; /// <summary> /// A locking behavior that will always block any operation while a write is requested. Mimicks the old SqliteRepository behavior. /// </summary> +/// <remarks> +/// Unsafe with asynchronous transactions; because <see cref="ReaderWriterLockSlim"/> is +/// thread-affine, holding it from <c>TransactionStarting</c> to <c>TransactionCommitted</c> +/// works only while continuations resume inline. A genuinely-async continuation inside a +/// transaction releases on another thread, throwing +/// <see cref="SynchronizationLockException"/> or deadlocking a later write. +/// </remarks> public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior { private readonly ILogger<PessimisticLockBehavior> _logger; @@ -47,7 +54,8 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior /// <inheritdoc/> public void Initialise(DbContextOptionsBuilder optionsBuilder) { - _logger.LogInformation("The database locking mode has been set to: Pessimistic."); + _logger.LogWarning( + "The database locking mode has been set to: Pessimistic. This mode is not safe with asynchronous transactions and can deadlock."); optionsBuilder.AddInterceptors(new CommandLockingInterceptor(_loggerFactory.CreateLogger<CommandLockingInterceptor>())); optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_loggerFactory.CreateLogger<TransactionLockingInterceptor>())); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs index 8556fb7bb3..ee36be035b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs @@ -61,6 +61,11 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity> builder.HasIndex(e => new { e.TopParentId, e.MediaType, e.IsVirtualItem, e.DateCreated }); // resume builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey }); + // alternate versions of an item, e.g. resolving the played date of a version onto its primary. + // Filtered: almost no item has a primary version, and an index covering those rows too would tempt + // the planner into serving "PrimaryVersionId IS NULL" - true for the whole library - out of it. + builder.HasIndex(e => e.PrimaryVersionId) + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); // sorted library queries (e.g., Series sorted by SortName) builder.HasIndex(e => new { e.Type, e.TopParentId, e.SortName }); // NextUp: per-series episode ordering (index seek + range scan on season/episode) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs index 2abccd41f0..b4013a394f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs @@ -13,8 +13,7 @@ public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEnti public void Configure(EntityTypeBuilder<LinkedChildEntity> builder) { builder.ToTable("LinkedChildren"); - builder.HasKey(e => new { e.ParentId, e.ChildId }); - builder.HasIndex(e => new { e.ParentId, e.SortOrder }); + builder.HasKey(e => new { e.ParentId, e.SortOrder }); builder.HasIndex(e => new { e.ParentId, e.ChildType }); builder.HasIndex(e => new { e.ChildId, e.ChildType }); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs index afa9eee363..48c537bbd3 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs @@ -13,5 +13,9 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration<MediaStream public void Configure(EntityTypeBuilder<MediaStreamInfo> builder) { builder.HasKey(e => new { e.ItemId, e.StreamIndex }); + + // Covering index for the stream filters. ItemId comes second because it is what they project and + // dedupe on; Language and IsExternal follow only to keep their predicates off the table. + builder.HasIndex(e => new { e.StreamType, e.ItemId, e.Language, e.IsExternal }); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs index 32ede86c96..7ebdbf4e8b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs @@ -15,7 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role }); builder.HasIndex(e => new { e.ItemId, e.SortOrder }); builder.HasIndex(e => new { e.ItemId, e.ListOrder }); - builder.HasIndex(e => e.PeopleId); + builder.HasIndex(e => new { e.PeopleId, e.ItemId }); builder.HasOne(e => e.Item); builder.HasOne(e => e.People); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs index d2aed54eb1..ae53a36724 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs @@ -14,10 +14,8 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration { // Used to get a user's permissions or a specific permission for a user. // Also prevents multiple values being created for a user. - // Filtered over non-null user ids for when other entities (groups, API keys) get permissions builder .HasIndex(p => new { p.UserId, p.Kind }) - .HasFilter("[UserId] IS NOT NULL") .IsUnique(); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs index 207051bcd1..5306078ed4 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs @@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration { builder .HasIndex(p => new { p.UserId, p.Kind }) - .HasFilter("[UserId] IS NOT NULL") .IsUnique(); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs index 05f2c80a25..72805ca19a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs @@ -117,8 +117,6 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); - b.HasIndex("UserId"); - b.HasIndex("UserId", "ItemId", "Client", "Key") .IsUnique(); @@ -176,8 +174,6 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); - b.HasIndex("UserId"); - b.HasIndex("UserId", "ItemId", "Client") .IsUnique(); @@ -291,12 +287,17 @@ namespace Jellyfin.Server.Implementations.Migrations .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + b.Property<bool>("Value") .HasColumnType("INTEGER"); b.HasKey("Id"); - b.HasIndex("Permission_Permissions_Guid"); + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); b.ToTable("Permissions"); }); @@ -317,6 +318,9 @@ namespace Jellyfin.Server.Implementations.Migrations .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + b.Property<string>("Value") .IsRequired() .HasMaxLength(65535) @@ -324,7 +328,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); - b.HasIndex("Preference_Preferences_Guid"); + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); b.ToTable("Preferences"); }); @@ -431,10 +437,14 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("Username") .IsRequired() .HasMaxLength(255) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .UseCollation("NOCASE"); b.HasKey("Id"); + b.HasIndex("Username") + .IsUnique(); + b.ToTable("Users"); }); @@ -469,7 +479,8 @@ namespace Jellyfin.Server.Implementations.Migrations { b.HasOne("Jellyfin.Data.Entities.User", null) .WithOne("ProfileImage") - .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId"); + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => @@ -485,14 +496,16 @@ namespace Jellyfin.Server.Implementations.Migrations { b.HasOne("Jellyfin.Data.Entities.User", null) .WithMany("Permissions") - .HasForeignKey("Permission_Permissions_Guid"); + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => { b.HasOne("Jellyfin.Data.Entities.User", null) .WithMany("Preferences") - .HasForeignKey("Preference_Preferences_Guid"); + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.Designer.cs index ddea37f6dd..a1e6604fe1 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20230923170422_UserCastReceiver.Designer.cs @@ -445,6 +445,37 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("DeviceOptions"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => { b.Property<Guid>("Id") diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.Designer.cs index ab7065ee65..8518b5720c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240729140605_AddMediaSegments.Designer.cs @@ -285,6 +285,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<Guid>("ItemId") .HasColumnType("TEXT"); + b.Property<string>("SegmentProviderId") + .HasColumnType("TEXT"); + b.Property<long>("StartTicks") .HasColumnType("INTEGER"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.Designer.cs index 9b72d9688a..2c0b18577e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.Designer.cs @@ -1247,8 +1247,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("Username") .IsRequired() .HasMaxLength(255) - .HasColumnType("TEXT") - .UseCollation("NOCASE"); + .HasColumnType("TEXT"); b.HasKey("Id"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.Designer.cs index f5cfe86c44..8d787678b5 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.Designer.cs @@ -1247,8 +1247,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("Username") .IsRequired() .HasMaxLength(255) - .HasColumnType("TEXT") - .UseCollation("NOCASE"); + .HasColumnType("TEXT"); b.HasKey("Id"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs index 434ea820af..83608cd26c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs @@ -229,6 +229,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int?>("IndexNumber") .HasColumnType("INTEGER"); + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + b.Property<int?>("InheritedParentalRatingValue") .HasColumnType("INTEGER"); @@ -1275,7 +1278,10 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int>("MaxActiveSessions") .HasColumnType("INTEGER"); - b.Property<int?>("MaxParentalAgeRating") + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") .HasColumnType("INTEGER"); b.Property<bool>("MustUpdatePassword") diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.Designer.cs index bad01778da..0ed52b2514 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.Designer.cs @@ -229,6 +229,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int?>("IndexNumber") .HasColumnType("INTEGER"); + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + b.Property<int?>("InheritedParentalRatingValue") .HasColumnType("INTEGER"); @@ -748,6 +751,24 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasAnnotation("Sqlite:UseSqlReturningClause", false); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => { b.Property<Guid>("Id") @@ -1260,7 +1281,10 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int>("MaxActiveSessions") .HasColumnType("INTEGER"); - b.Property<int?>("MaxParentalAgeRating") + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") .HasColumnType("INTEGER"); b.Property<bool>("MustUpdatePassword") @@ -1519,6 +1543,17 @@ namespace Jellyfin.Server.Implementations.Migrations b.Navigation("ItemValue"); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => { b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs index d668eea92f..e4bbb199b2 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.Designer.cs @@ -750,6 +750,24 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasAnnotation("Sqlite:UseSqlReturningClause", false); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => { b.Property<Guid>("Id") @@ -847,6 +865,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int?>("ElPresentFlag") .HasColumnType("INTEGER"); + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + b.Property<int?>("Height") .HasColumnType("INTEGER"); @@ -1521,6 +1542,17 @@ namespace Jellyfin.Server.Implementations.Migrations b.Navigation("ItemValue"); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => { b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.Designer.cs index d7672b1379..86fd7be9d8 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.Designer.cs @@ -123,7 +123,6 @@ namespace Jellyfin.Server.Implementations.Migrations .HasColumnType("INTEGER"); b.Property<string>("Codec") - .IsRequired() .HasColumnType("TEXT"); b.Property<string>("CodecTag") @@ -751,6 +750,24 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasAnnotation("Sqlite:UseSqlReturningClause", false); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => { b.Property<Guid>("Id") @@ -848,6 +865,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int?>("ElPresentFlag") .HasColumnType("INTEGER"); + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + b.Property<int?>("Height") .HasColumnType("INTEGER"); @@ -1522,6 +1542,17 @@ namespace Jellyfin.Server.Implementations.Migrations b.Navigation("ItemValue"); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => { b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.Designer.cs index 4ba3352edc..035b574fb8 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.Designer.cs @@ -183,9 +183,6 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<DateTime?>("DateCreated") .HasColumnType("TEXT"); - b.Property<DateTime?>("DateCreatedFilesystem") - .HasColumnType("TEXT"); - b.Property<DateTime?>("DateLastMediaAdded") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs index a7f5e369ab..156f553fb3 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs @@ -11,27 +11,19 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.Sql(""" -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -DELETE FROM BaseItems - WHERE - ParentId IS NOT NULL - AND - NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId); -"""); + WITH RECURSIVE Orphan ("Id") AS ( + SELECT Child."Id" + FROM "BaseItems" AS Child + WHERE Child."ParentId" IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM "BaseItems" AS Parent WHERE Parent."Id" = Child."ParentId") + UNION + SELECT Descendant."Id" + FROM "BaseItems" AS Descendant + INNER JOIN Orphan ON Descendant."ParentId" = Orphan."Id" + ) + DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM Orphan); + """); + migrationBuilder.AddForeignKey( name: "FK_BaseItems_BaseItems_ParentId", table: "BaseItems", diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs index 4927b0e78d..379da0e9be 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs @@ -11,6 +11,61 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations /// <inheritdoc /> protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.Sql( + """ + DROP TABLE IF EXISTS "OrphanedBaseItemIds"; + CREATE TEMPORARY TABLE "OrphanedBaseItemIds" ("Id" TEXT NOT NULL PRIMARY KEY); + + INSERT INTO "OrphanedBaseItemIds" ("Id") + WITH RECURSIVE Orphan ("Id") AS ( + SELECT Child."Id" + FROM "BaseItems" AS Child + WHERE Child."ParentId" IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM "BaseItems" AS Parent WHERE Parent."Id" = Child."ParentId") + UNION + SELECT Descendant."Id" + FROM "BaseItems" AS Descendant + INNER JOIN Orphan ON Descendant."ParentId" = Orphan."Id" + ) + SELECT "Id" FROM Orphan; + + -- Keep the play state of the doomed items the way ItemPersistenceService does when it + -- deletes an item: reattach it to the placeholder item instead of letting the + -- FK_UserData_BaseItems_ItemId cascade wipe it. The placeholder can only hold one row + -- per (UserId, CustomDataKey), so resolve collisions before repointing anything. + DELETE FROM "UserData" + WHERE "ItemId" = '00000000-0000-0000-0000-000000000001' + AND EXISTS ( + SELECT 1 + FROM "UserData" AS Doomed + INNER JOIN "OrphanedBaseItemIds" AS Orphan ON Orphan."Id" = Doomed."ItemId" + WHERE Doomed."UserId" = "UserData"."UserId" + AND Doomed."CustomDataKey" = "UserData"."CustomDataKey"); + + DELETE FROM "UserData" + WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds") + AND "rowid" NOT IN ( + SELECT MIN("rowid") + FROM "UserData" + WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds") + GROUP BY "UserId", "CustomDataKey"); + + UPDATE "UserData" + SET "ItemId" = '00000000-0000-0000-0000-000000000001', + "RetentionDate" = datetime('now') + WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds"); + + -- FK_LinkedChildren_BaseItems_{ParentId,ChildId} are NO ACTION, so these rows have to + -- go by hand or the delete below fails on them. + DELETE FROM "LinkedChildren" + WHERE "ParentId" IN (SELECT "Id" FROM "OrphanedBaseItemIds") + OR "ChildId" IN (SELECT "Id" FROM "OrphanedBaseItemIds"); + + DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM "OrphanedBaseItemIds"); + + DROP TABLE "OrphanedBaseItemIds"; + """); + // Normalize OwnerId to uppercase GUID format migrationBuilder.Sql( @"UPDATE BaseItems diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.Designer.cs index f9cb9aa736..db60cdabb4 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.Designer.cs @@ -955,6 +955,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.Designer.cs index 29874264af..2419977cfc 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.Designer.cs @@ -952,6 +952,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.Designer.cs index 8282a8a582..5b961bdd5b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.Designer.cs @@ -952,6 +952,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.Designer.cs index 5541a0191b..4d7e68d45f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.Designer.cs @@ -954,6 +954,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs index f6fd1db21e..33e7ebef95 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.Designer.cs @@ -956,6 +956,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.Designer.cs index 5f7131ff65..6131100f4f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.Designer.cs @@ -956,6 +956,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260215201634_ChangePrimaryVersionIdToGuid.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260215201634_ChangePrimaryVersionIdToGuid.Designer.cs index 0499921fec..0e63fe75dc 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260215201634_ChangePrimaryVersionIdToGuid.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260215201634_ChangePrimaryVersionIdToGuid.Designer.cs @@ -956,6 +956,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.Designer.cs index bf46ad9b39..52c07c717d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.Designer.cs @@ -958,6 +958,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.Designer.cs index fc5c7afa0e..e6f24c1d95 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.Designer.cs @@ -961,6 +961,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs index 63f858bc98..d69d880e7f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs @@ -961,6 +961,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs index a1f555a59b..871d5d2110 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.Designer.cs @@ -961,6 +961,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs new file mode 100644 index 0000000000..d5b6bd1d51 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs @@ -0,0 +1,1808 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260723111547_AllowDuplicatePlaylistChildren")] + partial class AllowDuplicatePlaylistChildren + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs new file mode 100644 index 0000000000..173213034e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class AllowDuplicatePlaylistChildren : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // Rows that predate the composite (ParentId, SortOrder) primary key stored a null SortOrder + // (e.g. BoxSet and Collection children). Assign each such row a stable 0-based position within + // its parent so the rows stay unique once SortOrder becomes part of the primary key; otherwise + // they would all collapse to the column default (0) and collide during the table rebuild. + migrationBuilder.Sql( + @"UPDATE ""LinkedChildren"" + SET ""SortOrder"" = ( + SELECT COUNT(*) + FROM ""LinkedChildren"" AS lc2 + WHERE lc2.""ParentId"" = ""LinkedChildren"".""ParentId"" + AND lc2.""rowid"" < ""LinkedChildren"".""rowid"" + ) + WHERE ""SortOrder"" IS NULL;"); + + migrationBuilder.DropPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren"); + + migrationBuilder.DropIndex( + name: "IX_LinkedChildren_ParentId_SortOrder", + table: "LinkedChildren"); + + migrationBuilder.AlterColumn<int>( + name: "SortOrder", + table: "LinkedChildren", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren", + columns: new[] { "ParentId", "SortOrder" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + // The (ParentId, ChildId) primary key cannot represent the same child more than once per + // parent. Drop any duplicate entries (keeping the first by SortOrder) that may have been + // created while duplicates were allowed, so the old key can be restored. This is lossy by + // nature — duplicate playlist entries cannot survive a downgrade. + migrationBuilder.Sql( + @"DELETE FROM ""LinkedChildren"" + WHERE ""rowid"" NOT IN ( + SELECT MIN(""rowid"") + FROM ""LinkedChildren"" + GROUP BY ""ParentId"", ""ChildId"" + );"); + + migrationBuilder.DropPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren"); + + migrationBuilder.AlterColumn<int>( + name: "SortOrder", + table: "LinkedChildren", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AddPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren", + columns: new[] { "ParentId", "ChildId" }); + + migrationBuilder.CreateIndex( + name: "IX_LinkedChildren_ParentId_SortOrder", + table: "LinkedChildren", + columns: new[] { "ParentId", "SortOrder" }); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260724185102_AddPrimaryVersionIdIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260724185102_AddPrimaryVersionIdIndex.Designer.cs new file mode 100644 index 0000000000..a8da9b4c23 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260724185102_AddPrimaryVersionIdIndex.Designer.cs @@ -0,0 +1,1811 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260724185102_AddPrimaryVersionIdIndex")] + partial class AddPrimaryVersionIdIndex + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260724185102_AddPrimaryVersionIdIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260724185102_AddPrimaryVersionIdIndex.cs new file mode 100644 index 0000000000..069104244c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260724185102_AddPrimaryVersionIdIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class AddPrimaryVersionIdIndex : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_BaseItems_PrimaryVersionId", + table: "BaseItems", + column: "PrimaryVersionId", + filter: "\"PrimaryVersionId\" IS NOT NULL"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_BaseItems_PrimaryVersionId", + table: "BaseItems"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.Designer.cs new file mode 100644 index 0000000000..39713d639c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.Designer.cs @@ -0,0 +1,1811 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260728170000_AddPeopleNameLowerIndex")] + partial class AddPeopleNameLowerIndex + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.cs new file mode 100644 index 0000000000..1f59630fe9 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728170000_AddPeopleNameLowerIndex.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class AddPeopleNameLowerIndex : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // Expression index, so it cannot be declared on the entity type. /Persons collapses the + // one-row-per-(Name, PersonType) table to one row per lowercased name; without this index + // that dedup scans and groups the whole table on every request. + migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS \"IX_Peoples_NameLower\" ON \"Peoples\" (lower(\"Name\"));"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_Peoples_NameLower\";"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.Designer.cs new file mode 100644 index 0000000000..6aacbab798 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.Designer.cs @@ -0,0 +1,1811 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260728182152_AddPeopleItemMapCoveringIndex")] + partial class AddPeopleItemMapCoveringIndex + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.cs new file mode 100644 index 0000000000..9be7953ada --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260728182152_AddPeopleItemMapCoveringIndex.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class AddPeopleItemMapCoveringIndex : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap"); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap", + columns: new[] { "PeopleId", "ItemId" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PeopleBaseItemMap_PeopleId_ItemId", + table: "PeopleBaseItemMap"); + + migrationBuilder.CreateIndex( + name: "IX_PeopleBaseItemMap_PeopleId", + table: "PeopleBaseItemMap", + column: "PeopleId"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs new file mode 100644 index 0000000000..afa6840a97 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs @@ -0,0 +1,1813 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260812050902_AddMediaStreamFilterIndex")] + partial class AddMediaStreamFilterIndex + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs new file mode 100644 index 0000000000..75187c6c4b --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class AddMediaStreamFilterIndex : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos", + columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs new file mode 100644 index 0000000000..c41b8b0106 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs @@ -0,0 +1,1807 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260815063607_RemoveOrphanedUserPermissionsAndPreferences")] + partial class RemoveOrphanedUserPermissionsAndPreferences + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs new file mode 100644 index 0000000000..2530e84af6 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class RemoveOrphanedUserPermissionsAndPreferences : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL OR UserId NOT IN (SELECT Id FROM Users);"); + migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL OR UserId NOT IN (SELECT Id FROM Users);"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.DropColumn( + name: "Preference_Preferences_Guid", + table: "Preferences"); + + migrationBuilder.DropColumn( + name: "Permission_Permissions_Guid", + table: "Permissions"); + + migrationBuilder.AlterColumn<Guid>( + name: "UserId", + table: "Preferences", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn<Guid>( + name: "UserId", + table: "Permissions", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: ["UserId", "Kind"], + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: ["UserId", "Kind"], + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.AlterColumn<Guid>( + name: "UserId", + table: "Preferences", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.AddColumn<Guid>( + name: "Preference_Preferences_Guid", + table: "Preferences", + type: "TEXT", + nullable: true); + + migrationBuilder.AlterColumn<Guid>( + name: "UserId", + table: "Permissions", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.AddColumn<Guid>( + name: "Permission_Permissions_Guid", + table: "Permissions", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: ["UserId", "Kind"], + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: ["UserId", "Kind"], + unique: true, + filter: "[UserId] IS NOT NULL"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs index fd18c035e6..0c35ccc992 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => { @@ -373,6 +373,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("PresentationUniqueKey"); + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + b.HasIndex("SeasonId"); b.HasIndex("SeriesId"); @@ -812,23 +815,21 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<Guid>("ParentId") .HasColumnType("TEXT"); + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + b.Property<Guid>("ChildId") .HasColumnType("TEXT"); b.Property<int>("ChildType") .HasColumnType("INTEGER"); - b.Property<int?>("SortOrder") - .HasColumnType("INTEGER"); - - b.HasKey("ParentId", "ChildId"); + b.HasKey("ParentId", "SortOrder"); b.HasIndex("ChildId", "ChildType"); b.HasIndex("ParentId", "ChildType"); - b.HasIndex("ParentId", "SortOrder"); - b.ToTable("LinkedChildren", (string)null); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); @@ -1011,6 +1012,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("ItemId", "StreamIndex"); + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); + b.ToTable("MediaStreamInfos"); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); @@ -1057,12 +1060,12 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("ItemId", "PeopleId", "Role"); - b.HasIndex("PeopleId"); - b.HasIndex("ItemId", "ListOrder"); b.HasIndex("ItemId", "SortOrder"); + b.HasIndex("PeopleId", "ItemId"); + b.ToTable("PeopleBaseItemMap"); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); @@ -1077,14 +1080,11 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int>("Kind") .HasColumnType("INTEGER"); - b.Property<Guid?>("Permission_Permissions_Guid") - .HasColumnType("TEXT"); - b.Property<uint>("RowVersion") .IsConcurrencyToken() .HasColumnType("INTEGER"); - b.Property<Guid?>("UserId") + b.Property<Guid>("UserId") .HasColumnType("TEXT"); b.Property<bool>("Value") @@ -1093,8 +1093,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); + .IsUnique(); b.ToTable("Permissions"); @@ -1110,14 +1109,11 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<int>("Kind") .HasColumnType("INTEGER"); - b.Property<Guid?>("Preference_Preferences_Guid") - .HasColumnType("TEXT"); - b.Property<uint>("RowVersion") .IsConcurrencyToken() .HasColumnType("INTEGER"); - b.Property<Guid?>("UserId") + b.Property<Guid>("UserId") .HasColumnType("TEXT"); b.Property<string>("Value") @@ -1128,8 +1124,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); + .IsUnique(); b.ToTable("Preferences"); @@ -1698,7 +1693,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) .WithMany("Permissions") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => @@ -1706,7 +1702,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) .WithMany("Preferences") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index 044fd0131f..3330b64b69 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data.Common; using System.Globalization; using System.IO; using System.Linq; @@ -63,7 +64,11 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider var sqliteConnectionBuilder = new SqliteConnectionStringBuilder { DataSource = GetOption(customOptions, "path", e => e, () => Path.Combine(_applicationPaths.DataPath, "jellyfin.db")), - Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default), + // Private, not Default: sqlite3_enable_shared_cache is process-global, so a plugin + // enabling it makes these connections share a cache too. Contention then surfaces as + // SQLITE_LOCKED ("database table is locked"), which the busy handler does not cover, + // so busy_timeout is skipped and the command fails at CommandTimeout instead. + Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Private), Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true), DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 60) }; @@ -99,17 +104,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider } /// <inheritdoc/> - public async Task RunScheduledOptimisation(CancellationToken cancellationToken) + public Task RunScheduledOptimisation(CancellationToken cancellationToken) { - var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await using (context.ConfigureAwait(false)) - { - await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); - await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); - _logger.LogInformation("jellyfin.db optimized successfully!"); - } + return OptimizeAsync(cancellationToken); } /// <inheritdoc/> @@ -121,19 +118,109 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider /// <inheritdoc/> public async Task RunShutdownTask(CancellationToken cancellationToken) { + // Run before disposing the application. Only a checkpoint: stopping is on a deadline. + + // Empty the pool first. Anything still parked in it can start reading again between here and the + // checkpoint, and a reader that holds the write-ahead log open is exactly what makes the truncation + // fail. Connections handed out already cannot be taken away, but they get disposed on return. + SqliteConnection.ClearAllPools(); + + try + { + if (DbContextFactory is not null) + { + var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + // A missed checkpoint only leaves a write-ahead log for the next start to replay, so never fail the + // shutdown over this. + _logger.LogError(ex, "Error while checkpointing jellyfin.db"); + } + + // The checkpointing connection went back into the pool, so retire that one as well. + SqliteConnection.ClearAllPools(); + } + + private async Task OptimizeAsync(CancellationToken cancellationToken) + { if (DbContextFactory is null) { return; } - // Run before disposing the application var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); + await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + try + { + long? tempStore; + long? analysisLimit; + var pragmaCommand = context.Database.GetDbConnection().CreateCommand(); + await using (pragmaCommand.ConfigureAwait(false)) + { + pragmaCommand.CommandText = "PRAGMA temp_store"; + tempStore = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false); + pragmaCommand.CommandText = "PRAGMA analysis_limit"; + analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false); + } + + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + + _logger.LogDebug( + "Rebuilding jellyfin.db on disk, scratch space goes to {TempDirectory}", + Environment.GetEnvironmentVariable("SQLITE_TMPDIR") ?? "SQLite's default temporary directory"); + await context.Database.ExecuteSqlRawAsync("PRAGMA temp_store=1", cancellationToken).ConfigureAwait(false); + try + { + await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); + } + finally + { + // The connection goes back to the pool, so hand it over the way it was handed to us. + if (tempStore is not null) + { + await context.Database.ExecuteSqlRawAsync( + FormattableString.Invariant($"PRAGMA temp_store={tempStore.Value}"), + CancellationToken.None).ConfigureAwait(false); + } + } + + await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false); + try + { + await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false); + } + finally + { + if (analysisLimit is not null) + { + await context.Database.ExecuteSqlRawAsync( + FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"), + CancellationToken.None).ConfigureAwait(false); + } + } + + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + finally + { + await context.Database.CloseConnectionAsync().ConfigureAwait(false); + } } + } - SqliteConnection.ClearAllPools(); + private static async Task<long?> ReadPragmaValueAsync(DbCommand command, CancellationToken cancellationToken) + { + var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return value is null or DBNull ? null : Convert.ToInt64(value, CultureInfo.InvariantCulture); } /// <inheritdoc/> @@ -143,16 +230,31 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider } /// <inheritdoc /> - public Task<string> MigrationBackupFast(CancellationToken cancellationToken) + public async Task<string> MigrationBackupFast(CancellationToken cancellationToken) { - var key = DateTime.UtcNow.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture); var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); - var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName); - Directory.CreateDirectory(backupFile); + var backupFolder = Path.Combine(_applicationPaths.DataPath, BackupFolderName); + Directory.CreateDirectory(backupFolder); + + if (DbContextFactory is not null && File.Exists(path)) + { + var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false); + } + } + + var key = DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); + var backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db"); + for (var attempt = 1; File.Exists(backupFile); attempt++) + { + key = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyyMMddHHmmss}_{attempt}"); + backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db"); + } - backupFile = Path.Combine(backupFile, $"{key}_jellyfin.db"); File.Copy(path, backupFile); - return Task.FromResult(key); + return key; } /// <inheritdoc /> @@ -169,10 +271,55 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider return Task.CompletedTask; } + if (!TryRetireWriteAheadLog(path)) + { + _logger.LogCritical( + "Refusing to restore jellyfin.db: the write-ahead log at {WriteAheadLog} could not be retired, which " + + "means the database is still open and replacing it now would silently bring back the data this " + + "rollback is undoing. Stop the server and copy {Backup} over {Path} by hand.", + path + "-wal", + backupFile, + path); + return Task.CompletedTask; + } + File.Copy(backupFile, path, true); + return Task.CompletedTask; } + private bool TryRetireWriteAheadLog(string path) + { + var writeAheadLogPath = path + "-wal"; + if (!File.Exists(path) || !File.Exists(writeAheadLogPath)) + { + return true; + } + + try + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadWrite, + Pooling = false + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + command.ExecuteNonQuery(); + } + catch (SqliteException ex) + { + // Either something else holds the database or it is too damaged to open. The check below covers both. + _logger.LogError(ex, "Could not open jellyfin.db to retire its write-ahead log"); + } + + return !File.Exists(writeAheadLogPath); + } + /// <inheritdoc /> public Task DeleteBackup(string key) { diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index b6d2914efa..b8d40614d2 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -1,8 +1,10 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using BlurHashSharp.SkiaSharp; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; @@ -11,6 +13,7 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Model.Drawing; using Microsoft.Extensions.Logging; using SkiaSharp; +using Svg; using Svg.Skia; namespace Jellyfin.Drawing.Skia; @@ -21,22 +24,15 @@ namespace Jellyfin.Drawing.Skia; public class SkiaEncoder : IImageEncoder { private const string SvgFormat = "svg"; + + // The light sharpening kernel applied after resizing, see ResizeImage. + private const float SharpenCenterWeight = 1.4f; + private const float SharpenNeighborWeight = -0.1f; + private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" }; private readonly ILogger<SkiaEncoder> _logger; private readonly IApplicationPaths _appPaths; private static readonly SKTypeface?[] _typefaces = InitializeTypefaces(); - private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution( - new SKSizeI(3, 3), - [ - 0, -.1f, 0, - -.1f, 1.4f, -.1f, - 0, -.1f, 0 - ], - 1f, - 0f, - new SKPointI(1, 1), - SKShaderTileMode.Clamp, - true); /// <summary> /// The default sampling options, equivalent to old high quality filter settings when upscaling. @@ -48,6 +44,13 @@ public class SkiaEncoder : IImageEncoder /// </summary> public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear); + static SkiaEncoder() + { + SvgDocument.ResolveExternalElements = ExternalType.None; + SvgDocument.ResolveExternalImages = ExternalType.None; + SvgDocument.ResolveExternalXmlEntites = ExternalType.None; + } + /// <summary> /// Initializes a new instance of the <see cref="SkiaEncoder"/> class. /// </summary> @@ -183,6 +186,12 @@ public class SkiaEncoder : IImageEncoder var extension = Path.GetExtension(path.AsSpan()); if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase)) { + if (!SvgSecurityValidator.IsSafe(path, out var reason)) + { + _logger.LogError("Refusing to determine dimensions for SVG {FilePath}: {Reason}", path, reason); + return default; + } + using var svg = new SKSvg(); try { @@ -445,6 +454,12 @@ public class SkiaEncoder : IImageEncoder throw new FileNotFoundException("File not found", path); } + if (!SvgSecurityValidator.IsSafe(path, out var reason)) + { + _logger.LogError("Refusing to render SVG {FilePath}: {Reason}", path, reason); + return null; + } + using var svg = SKSvg.CreateFromFile(path); if (svg.Drawable is null) { @@ -541,8 +556,15 @@ public class SkiaEncoder : IImageEncoder /// <returns>The resized image.</returns> internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false) { - using var surface = SKSurface.Create(targetInfo); - using var canvas = surface.Canvas; + if (source.Width == targetInfo.Width && source.Height == targetInfo.Height) + { + return SKImage.FromBitmap(source); + } + + var isDownscale = source.Width > targetInfo.Width || source.Height > targetInfo.Height; + + using var target = new SKBitmap(targetInfo); + using var canvas = new SKCanvas(target); using var paint = new SKPaint(); paint.IsAntialias = isAntialias; paint.IsDither = isDither; @@ -550,11 +572,10 @@ public class SkiaEncoder : IImageEncoder // Historically, kHigh implied cubic filtering, but only when upsampling. // If specified kHigh, and were down-sampling, Skia used to switch back to kMedium (bilinear filtering plus mipmaps). // With current skia API, passing Mitchell cubic when down-sampling will cause serious quality degradation. - var samplingOptions = source.Width > targetInfo.Width || source.Height > targetInfo.Height + var samplingOptions = isDownscale ? DefaultSamplingOptions : UpscaleSamplingOptions; - paint.ImageFilter = _imageFilter; canvas.DrawBitmap( source, SKRect.Create(0, 0, source.Width, source.Height), @@ -562,7 +583,78 @@ public class SkiaEncoder : IImageEncoder samplingOptions, paint); - return surface.Snapshot(); + if (isDownscale) + { + SharpenInPlace(target); + } + + return SKImage.FromBitmap(target); + } + + /// <summary> + /// Applies the light 3x3 sharpening kernel to the bitmap in place. + /// + /// This is equivalent to the SKImageFilter.CreateMatrixConvolution paint filter that + /// was previously part of the resize draw call. Since the SkiaSharp 3 update that + /// filter no longer has a fast CPU path and takes multiple seconds per image on the + /// software rasterizer, so the same kernel is applied directly instead. + /// </summary> + /// <param name="bitmap">The bitmap to sharpen. Must use a color type with four bytes per pixel; other color types are returned unchanged.</param> + internal static void SharpenInPlace(SKBitmap bitmap) + { + if (bitmap.BytesPerPixel != 4) + { + return; + } + + var width = bitmap.Width; + var height = bitmap.Height; + var stride = bitmap.RowBytes; + var pixels = bitmap.GetPixels(); + if (width == 0 || height == 0 || pixels == IntPtr.Zero) + { + return; + } + + var length = stride * height; + var source = ArrayPool<byte>.Shared.Rent(length); + var result = ArrayPool<byte>.Shared.Rent(length); + try + { + Marshal.Copy(pixels, source, 0, length); + + for (var y = 0; y < height; y++) + { + // The kernel clamps at the edges: out-of-bounds taps reuse the edge pixel. + var row = y * stride; + var up = y == 0 ? row : row - stride; + var down = y == height - 1 ? row : row + stride; + + for (var x = 0; x < width; x++) + { + var col = x * 4; + var left = x == 0 ? col : col - 4; + var right = x == width - 1 ? col : col + 4; + + for (var channel = 0; channel < 4; channel++) + { + var value = (SharpenCenterWeight * source[row + col + channel]) + + (SharpenNeighborWeight * (source[up + col + channel] + + source[down + col + channel] + + source[row + left + channel] + + source[row + right + channel])); + result[row + col + channel] = (byte)Math.Clamp((int)(value + 0.5f), 0, 255); + } + } + } + + Marshal.Copy(result, 0, pixels, length); + } + finally + { + ArrayPool<byte>.Shared.Return(source); + ArrayPool<byte>.Shared.Return(result); + } } /// <inheritdoc/> diff --git a/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs new file mode 100644 index 0000000000..65f35643b2 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs @@ -0,0 +1,339 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Compression; +using System.Runtime.CompilerServices; +using System.Text; +using System.Xml; + +[assembly: InternalsVisibleTo("Jellyfin.Drawing.Skia.Tests")] + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Validates that an SVG document does not reference external resources before it is rasterized. +/// </summary> +internal static class SvgSecurityValidator +{ + // Guards against a chain of nested data:image/svg+xml payloads. + private const int MaxDataUriDepth = 4; + + // Upper bound for a decompressed svgz payload carried inside a data URI, to guard against decompression bombs. + private const int MaxDecompressedBytes = 16 * 1024 * 1024; + + private const int DecompressBufferSize = 81920; + + private static readonly XmlReaderSettings _scanSettings = new() + { + DtdProcessing = DtdProcessing.Parse, + XmlResolver = null, + MaxCharactersFromEntities = 1024 * 1024, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + IgnoreWhitespace = true, + CloseInput = false + }; + + /// <summary> + /// Determines whether the SVG at the given path is safe to rasterize, i.e. contains no references + /// to external resources. + /// </summary> + /// <param name="path">The path to the SVG file.</param> + /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param> + /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns> + public static bool IsSafe(string path, [NotNullWhen(false)] out string? reason) + { + try + { + using var stream = File.OpenRead(path); + reason = Validate(stream, 0); + } + catch (IOException ex) + { + reason = "Unable to read the file for validation: " + ex.Message; + } + catch (UnauthorizedAccessException ex) + { + reason = "Unable to read the file for validation: " + ex.Message; + } + + return reason is null; + } + + /// <summary> + /// Determines whether the SVG in the given stream is safe to rasterize. + /// </summary> + /// <param name="stream">The stream containing the SVG document.</param> + /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param> + /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns> + public static bool IsSafe(Stream stream, [NotNullWhen(false)] out string? reason) + { + reason = Validate(stream, 0); + return reason is null; + } + + private static string? Validate(Stream stream, int depth) + { + try + { + using var reader = XmlReader.Create(stream, _scanSettings); + while (reader.Read()) + { + switch (reader.NodeType) + { + case XmlNodeType.DocumentType: + { + var subset = reader.Value; + if (!string.IsNullOrEmpty(subset) + && (subset.Contains("SYSTEM", StringComparison.OrdinalIgnoreCase) + || subset.Contains("PUBLIC", StringComparison.OrdinalIgnoreCase))) + { + return "The document declares an external DTD entity"; + } + + break; + } + + case XmlNodeType.Element when reader.HasAttributes: + { + for (var i = 0; i < reader.AttributeCount; i++) + { + reader.MoveToAttribute(i); + var isHref = reader.LocalName.Equals("href", StringComparison.OrdinalIgnoreCase); + var reason = isHref + ? ValidateReference(reader.Value, depth, "href") + : ValidateCss(reader.Value, depth); + if (reason is not null) + { + return reason; + } + } + + reader.MoveToElement(); + break; + } + + case XmlNodeType.Text: + case XmlNodeType.CDATA: + { + var reason = ValidateCss(reader.Value, depth); + if (reason is not null) + { + return reason; + } + + break; + } + } + } + + return null; + } + catch (XmlException ex) + { + // Malformed markup, a forbidden DTD construct or an unresolved external entity: refuse to render. + return "The document could not be safely parsed: " + ex.Message; + } + } + + private static string? ValidateReference(ReadOnlySpan<char> value, int depth, string context) + { + var trimmed = value.Trim(); + if (trimmed.IsEmpty || trimmed[0] == '#') + { + return null; + } + + if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return ValidateDataUri(trimmed, depth, context); + } + + return "An external resource is referenced via " + context; + } + + private static string? ValidateDataUri(ReadOnlySpan<char> dataUri, int depth, string context) + { + // "data:[<mediatype>][;base64],<payload>" (mirrors Svg.Model's data URI parsing). + var comma = dataUri.IndexOf(','); + if (comma < 0) + { + return "A malformed data URI is referenced via " + context; + } + + var header = dataUri[5..comma]; + var firstSeparator = header.IndexOf(';'); + var mediaType = (firstSeparator < 0 ? header : header[..firstSeparator]).Trim(); + + // Only "image/svg+xml" is re-parsed as SVG by the renderer; any other type is treated as raster data. + if (!mediaType.Contains('/') || !mediaType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (depth >= MaxDataUriDepth) + { + return "Nested data URIs exceed the allowed depth"; + } + + var lastSeparator = header.LastIndexOf(';'); + var isBase64 = lastSeparator >= 0 + && header[(lastSeparator + 1)..].Trim().Equals("base64", StringComparison.OrdinalIgnoreCase); + + var payload = dataUri[(comma + 1)..].Trim(); + byte[]? buffer = null; + try + { + int length; + if (isBase64) + { + buffer = ArrayPool<byte>.Shared.Rent((payload.Length / 4 * 3) + 3); + if (!Convert.TryFromBase64Chars(payload, buffer, out length)) + { + return "An undecodable data URI is referenced via " + context; + } + } + else + { + var unescaped = Uri.UnescapeDataString(payload.ToString()); + buffer = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(unescaped.Length)); + length = Encoding.UTF8.GetBytes(unescaped, buffer); + } + + if (length > 2 && buffer[0] == 0x1F && buffer[1] == 0x8B) + { + using var decompressed = Decompress(buffer, length); + return Validate(decompressed, depth + 1); + } + + using var stream = new MemoryStream(buffer, 0, length, false); + return Validate(stream, depth + 1); + } + catch (FormatException ex) + { + return "An undecodable data URI is referenced via " + context + ": " + ex.Message; + } + catch (InvalidDataException ex) + { + return "An invalid compressed data URI is referenced via " + context + ": " + ex.Message; + } + finally + { + if (buffer is not null) + { + ArrayPool<byte>.Shared.Return(buffer); + } + } + } + + private static MemoryStream Decompress(byte[] compressed, int length) + { + using var input = new MemoryStream(compressed, 0, length, false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + var output = new MemoryStream(); + var buffer = ArrayPool<byte>.Shared.Rent(DecompressBufferSize); + try + { + var total = 0; + int read; + while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (total > MaxDecompressedBytes) + { + throw new InvalidDataException("Compressed data URI exceeds the allowed size"); + } + + output.Write(buffer, 0, read); + } + } + catch + { + output.Dispose(); + throw; + } + finally + { + ArrayPool<byte>.Shared.Return(buffer); + } + + output.Position = 0; + return output; + } + + private static string? ValidateCss(ReadOnlySpan<char> value, int depth) + { + if (value.IsEmpty) + { + return null; + } + + var index = 0; + while (true) + { + var found = value[index..].IndexOf("url(", StringComparison.OrdinalIgnoreCase); + if (found < 0) + { + break; + } + + var start = index + found + 4; + var close = value[start..].IndexOf(')'); + if (close < 0) + { + break; + } + + var target = value.Slice(start, close).Trim(); + target = target.Trim('\''); + target = target.Trim('"').Trim(); + var reason = ValidateReference(target, depth, "url()"); + if (reason is not null) + { + return reason; + } + + index = start + close + 1; + if (index >= value.Length) + { + break; + } + } + + // Handle the bare "@import '...';" form (the "@import url(...)" form is covered above). + index = 0; + while (true) + { + var found = value[index..].IndexOf("@import", StringComparison.OrdinalIgnoreCase); + if (found < 0) + { + break; + } + + var rest = value[(index + found + 7)..]; + var quote = rest.IndexOfAny('\'', '"'); + if (quote >= 0) + { + var afterQuote = rest[(quote + 1)..]; + var end = afterQuote.IndexOfAny('\'', '"'); + if (end >= 0) + { + var reason = ValidateReference(afterQuote[..end], depth, "@import"); + if (reason is not null) + { + return reason; + } + } + } + + index = index + found + 7; + if (index >= value.Length) + { + break; + } + } + + return null; + } +} diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 6ffb022842..ad1b216970 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -31,7 +31,7 @@ namespace Jellyfin.Drawing; public sealed class ImageProcessor : IImageProcessor, IDisposable { // Increment this when there's a change requiring caches to be invalidated - private const char Version = '3'; + private const char Version = '4'; private static readonly HashSet<string> _transparentImageTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif", ".svg" }; @@ -251,6 +251,33 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable /// <summary> /// Gets the cache file path based on a set of parameters. /// </summary> + /// <param name="originalPath">The original image path.</param> + /// <param name="dateModified">The source image modification date.</param> + /// <param name="format">The output format.</param> + /// <param name="options">The image processing options.</param> + /// <returns>The transformed image cache path.</returns> + internal string GetCacheFilePath( + string originalPath, + DateTime dateModified, + ImageFormat format, + ImageProcessingOptions options) + => GetCacheFilePath( + originalPath, + options.Width, + options.Height, + options.MaxWidth, + options.MaxHeight, + options.FillWidth, + options.FillHeight, + options.Quality, + dateModified, + format, + options.PercentPlayed, + options.UnplayedCount, + options.Blur, + options.BackgroundColor, + options.ForegroundLayer); + private string GetCacheFilePath( string originalPath, int? width, @@ -318,13 +345,13 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable if (percentPlayed > 0) { - filename.Append(",p="); - filename.Append(percentPlayed); + filename.Append(",pp="); + filename.Append(percentPlayed.ToString(CultureInfo.InvariantCulture)); } if (unwatchedCount.HasValue) { - filename.Append(",p="); + filename.Append(",uc="); filename.Append(unwatchedCount.Value); } diff --git a/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs index 3851bf9241..3d39372313 100644 --- a/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs +++ b/src/Jellyfin.Drawing/Properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -12,6 +13,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Integration.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 5518d9b954..bdffecdc8a 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -15,7 +15,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Extensions</PackageId> - <VersionPrefix>12.0.0</VersionPrefix> + <VersionPrefix>13.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/src/Jellyfin.Extensions/PathHelper.cs b/src/Jellyfin.Extensions/PathHelper.cs new file mode 100644 index 0000000000..ab74a7749d --- /dev/null +++ b/src/Jellyfin.Extensions/PathHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; + +namespace Jellyfin.Extensions; + +/// <summary> +/// Helpers for safely composing filesystem paths from untrusted input. +/// </summary> +/// <remarks> +/// <see cref="Path.Combine(string, string)"/> has two issues that matter in +/// any code that joins a trusted directory with an externally-supplied name: +/// it neither normalises <c>..</c> nor rejects a rooted second argument +/// (a rooted second arg silently discards the first). Use the helpers below +/// any time the name comes from media metadata, request input, archive +/// entries, or any other channel that can be influenced by a third party. +/// </remarks> +public static class PathHelper +{ + /// <summary> + /// Reduces a possibly-untrusted file name to a safe leaf-only name with no + /// directory components. + /// </summary> + /// <param name="fileName">The candidate file name.</param> + /// <returns> + /// The leaf component of <paramref name="fileName"/>, or <c>null</c> if + /// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>). + /// </returns> + public static string? GetSafeLeafFileName(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return null; + } + + var leaf = Path.GetFileName(fileName); + if (string.IsNullOrEmpty(leaf) || string.Equals(leaf, ".", StringComparison.Ordinal) || string.Equals(leaf, "..", StringComparison.Ordinal)) + { + return null; + } + + return leaf; + } + + /// <summary> + /// Returns whether <paramref name="candidate"/> resolves to a path that + /// equals or is contained inside <paramref name="root"/>. + /// </summary> + /// <param name="root">The directory the candidate must remain inside.</param> + /// <param name="candidate">The candidate absolute or relative path.</param> + /// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns> + /// <remarks> + /// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/> + /// so <c>..</c> segments are collapsed before the comparison. The root is + /// compared with a trailing directory separator to prevent prefix + /// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of + /// <c>/var/dataset</c>). + /// </remarks> + public static bool IsContainedIn(string root, string candidate) + { + ArgumentException.ThrowIfNullOrEmpty(root); + ArgumentException.ThrowIfNullOrEmpty(candidate); + + var fullRoot = Path.GetFullPath(root); + var fullCandidate = Path.GetFullPath(candidate); + + if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal)) + { + return true; + } + + var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal); + } +} diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index 906efbcbcc..38f1cf738f 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using ICU4N.Text; @@ -173,5 +174,41 @@ namespace Jellyfin.Extensions return cleaned; } + + /// <summary> + /// Escapes an argument so that it survives command line parsing as a single argument when it is wrapped in double quotes by the caller. + /// </summary> + /// <param name="value">The argument to escape.</param> + /// <returns>The escaped argument.</returns> + public static string EscapeProcessArgument(this string value) + { + ArgumentNullException.ThrowIfNull(value); + + var span = value.AsSpan(); + if (!span.Contains('"')) + { + var trailing = span.Length - span.TrimEnd('\\').Length; + return trailing == 0 ? value : string.Concat(value, new string('\\', trailing)); + } + + var escaped = new StringBuilder(value.Length + 8); + var backslashes = 0; + + foreach (var character in span) + { + if (character == '\\') + { + backslashes++; + continue; + } + + escaped + .Append('\\', character == '"' ? (backslashes * 2) + 1 : backslashes) + .Append(character); + backslashes = 0; + } + + return escaped.Append('\\', backslashes * 2).ToString(); + } } } diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index e421601092..256a652fd2 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -14,7 +14,6 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; -using Jellyfin.LiveTv; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -373,9 +372,7 @@ namespace Jellyfin.LiveTv.Channels { IEnumerable<MediaSourceInfo> results = GetSavedMediaSources(item); - return results - .Select(i => NormalizeMediaSource(item, i)) - .ToList(); + return NormalizeMediaSources(item, results); } /// <summary> @@ -401,9 +398,7 @@ namespace Jellyfin.LiveTv.Channels results = Enumerable.Empty<MediaSourceInfo>(); } - return results - .Select(i => NormalizeMediaSource(item, i)) - .ToList(); + return NormalizeMediaSources(item, results); } private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken) @@ -421,11 +416,36 @@ namespace Jellyfin.LiveTv.Channels return list; } - private static MediaSourceInfo NormalizeMediaSource(BaseItem item, MediaSourceInfo info) + private static IReadOnlyList<MediaSourceInfo> NormalizeMediaSources(BaseItem item, IEnumerable<MediaSourceInfo> infos) { - info.RunTimeTicks ??= item.RunTimeTicks; + var list = infos.ToList(); + var itemId = item.Id.ToString("N", CultureInfo.InvariantCulture); + var hasDefaultSource = list.Any(i => string.Equals(i.Id, itemId, StringComparison.OrdinalIgnoreCase)); - return info; + for (var index = 0; index < list.Count; index++) + { + var info = list[index]; + info.RunTimeTicks ??= item.RunTimeTicks; + + if (!string.IsNullOrEmpty(info.Id)) + { + continue; + } + + if (!hasDefaultSource) + { + // The source carrying the item id sorts first and becomes the client's default. + info.Id = itemId; + hasDefaultSource = true; + continue; + } + + // Remaining sources need ids that are distinct but stable, as clients send them back to request playback. + var key = string.IsNullOrEmpty(info.Path) ? index.ToString(CultureInfo.InvariantCulture) : info.Path; + info.Id = (itemId + key).GetMD5().ToString("N", CultureInfo.InvariantCulture); + } + + return list; } private async Task<Channel> GetChannel(IChannel channelInfo, CancellationToken cancellationToken) @@ -1110,8 +1130,9 @@ namespace Jellyfin.LiveTv.Channels item.Path = mediaSource?.Path; } - if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl)) + if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) { + item.SetImagePath(ImageType.Primary, info.ImageUrl); _logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name); forceUpdate = true; } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index b8545cbb64..a11f83f2f8 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -5,8 +5,8 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; -using Jellyfin.LiveTv; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Listings; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -125,12 +125,16 @@ public class GuideManager : IGuideManager { var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService)); - var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + var (channelIds, programIds, hasErrors) = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); - newChannelIdList.AddRange(idList.Item1); - newProgramIdList.AddRange(idList.Item2); + newChannelIdList.AddRange(channelIds); + newProgramIdList.AddRange(programIds); + + // The channels that failed did not report any programs, so cleaning the database + // would delete every program they provide instead of keeping the previous ones. + cleanDatabase &= !hasErrors; } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } @@ -172,10 +176,12 @@ public class GuideManager : IGuideManager : 7; } - private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken) + private async Task<(List<Guid> ChannelIds, List<Guid> ProgramIds, bool HasErrors)> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken) { progress.Report(10); + var hasErrors = false; + var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) .Select(i => new Tuple<string, ChannelInfo>(service.Name, i)) .ToList(); @@ -195,12 +201,13 @@ public class GuideManager : IGuideManager list.Add(item); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { + hasErrors = true; _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); } @@ -314,12 +321,13 @@ public class GuideManager : IGuideManager }, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { + hasErrors = true; _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); } @@ -330,7 +338,7 @@ public class GuideManager : IGuideManager } progress.Report(100); - return new Tuple<List<Guid>, List<Guid>>(channels, programIds); + return (channels, programIds, hasErrors); } private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) @@ -449,9 +457,23 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl)) + var currentPrimary = item.GetImageInfo(ImageType.Primary, 0); + var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl); + + // Update channel image if image URL has changed + if (currentPrimary is null + || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal))) { - forceUpdate = true; + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!imageUrlIsNull) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } } if (isNew) @@ -486,8 +508,13 @@ public class GuideManager : IGuideManager DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - - item.TrySetProviderId(EtagKey, info.Etag); + } + else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey))) + { + // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes, + // so an exact match means nothing relevant changed. Other providers stay on the + // field-by-field update path. + return (item, false, false); } if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) @@ -613,13 +640,9 @@ public class GuideManager : IGuideManager forceUpdate |= UpdateImages(item, info); - if (isNew) - { - item.OnMetadataChanged(); - - return (item, true, false); - } - + // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and + // persist it on new items so they join the fast path on the next refresh + // instead of taking an extra full processing cycle. var isUpdated = forceUpdate; var etag = info.Etag; if (string.IsNullOrWhiteSpace(etag)) @@ -632,6 +655,13 @@ public class GuideManager : IGuideManager isUpdated = true; } + if (isNew) + { + item.OnMetadataChanged(); + + return (item, true, false); + } + if (isUpdated) { item.OnMetadataChanged(); diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index 19c4514766..633c4f95ed 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -188,8 +188,8 @@ namespace Jellyfin.LiveTv.IO var commandLineArgs = string.Format( CultureInfo.InvariantCulture, "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", - inputTempFile, - targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename + inputTempFile.EscapeProcessArgument(), + targetFile.EscapeProcessArgument(), videoArgs, GetAudioArgs(mediaSource), subtitleArgs, diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 15e20d6f64..7274a3030c 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -352,9 +352,12 @@ public class ListingsManager : IListingsManager var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml"); try { - File.Delete(xmltvCacheFile); + if (File.Exists(xmltvCacheFile)) + { + File.Delete(xmltvCacheFile); + } } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId); } diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index d456bea469..40ec99e9a5 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -491,6 +491,12 @@ namespace Jellyfin.LiveTv.Listings var results = new List<ShowImagesDto>(); for (int i = 0; i < programIds.Count; i += BatchSize) { + // The daily image limit may be surfaced mid-batch. + if (IsImageDailyLimitActive()) + { + break; + } + var batch = programIds.Skip(i).Take(BatchSize); using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/"); @@ -511,6 +517,18 @@ namespace Jellyfin.LiveTv.Listings entry.ProgramId, entry.Code, entry.Message); + + // The image download limit can be reported per-entry inside an + // otherwise successful (HTTP 200) response when the limit is hit + // mid-batch. Back off so we stop requesting images until SD resets. + if (entry.Code is (int)SdErrorCode.MaxImageDownloads or (int)SdErrorCode.MaxImageDownloadsTrial) + { + _logger.LogError( + "Schedules Direct image download limit hit (code {Code}). Disabling image acquisition until SD reset.", + entry.Code); + SetImageLimitHit(); + } + continue; } @@ -531,46 +549,52 @@ namespace Jellyfin.LiveTv.Listings { var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - var lineups = new List<NameIdPair>(); - if (string.IsNullOrWhiteSpace(token)) { - return lineups; + throw new AuthenticationException("Could not authenticate with Schedules Direct"); } + var lineups = new List<NameIdPair>(); + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); options.Headers.TryAddWithoutValidation("token", token); - try + var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false); + foreach (HeadendsDto headend in root ?? []) { - var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false); - if (root is not null) + foreach (LineupDto lineup in headend.Lineups ?? []) { - foreach (HeadendsDto headend in root) + lineups.Add(new NameIdPair { - foreach (LineupDto lineup in headend.Lineups) - { - lineups.Add(new NameIdPair - { - Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, - Id = lineup.Uri?[18..] - }); - } - } - } - else - { - _logger.LogInformation("No lineups available"); + Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, + Id = string.IsNullOrWhiteSpace(lineup.Lineup) ? lineup.Uri?.Split('/')[^1] : lineup.Lineup + }); } } - catch (Exception ex) + + if (lineups.Count == 0) { - _logger.LogError(ex, "Error getting headends"); + _logger.LogWarning( + "Schedules Direct has no lineups for country {Country} and postal code {PostalCode}", + country, + location); } return lineups; } + private void ResetErrorState(ListingsProviderInfo info) + { + _accountError = false; + Interlocked.Exchange(ref _lastErrorResponseTicks, 0); + + // Only the account being saved is retried, the tokens of the other accounts stay valid. + if (!string.IsNullOrWhiteSpace(info.Username)) + { + _tokens.TryRemove(info.Username, out _); + } + } + private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) { var username = info.Username; @@ -587,15 +611,19 @@ namespace Jellyfin.LiveTv.Listings return null; } - // Permanent account error — SD is disabled for this server lifetime. + // Account error — SD stays disabled until the provider is saved again or the server restarts. if (_accountError) { + _logger.LogWarning("Skipping Schedules Direct request because of an earlier account error. Save the listings provider again to retry."); + return null; } // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout) if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30) { + _logger.LogWarning("Skipping Schedules Direct request because of a recent login failure. Retrying no earlier than 30 minutes after it."); + return null; } @@ -758,7 +786,7 @@ namespace Jellyfin.LiveTv.Listings return root.Token; } - throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message); + throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + (root?.Message ?? "empty response")); } private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) @@ -974,10 +1002,18 @@ namespace Jellyfin.LiveTv.Listings public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) { + ResetErrorState(info); + if (validateLogin) { ArgumentException.ThrowIfNullOrEmpty(info.Username); ArgumentException.ThrowIfNullOrEmpty(info.Password); + + var token = await GetToken(info, CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token)) + { + throw new AuthenticationException("Could not authenticate with Schedules Direct"); + } } if (validateListings) diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index 7088d1f0bf..f78d464659 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -27,11 +28,14 @@ namespace Jellyfin.LiveTv.Listings public class XmlTvListingsProvider : IListingsProvider { private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1); + private static readonly TimeSpan _downloadTimeout = TimeSpan.FromMinutes(15); private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<XmlTvListingsProvider> _logger; + private readonly ConcurrentDictionary<string, DateTime> _lastDownloadFailures = new(StringComparer.Ordinal); + public XmlTvListingsProvider( IServerConfigurationManager config, IHttpClientFactory httpClientFactory, @@ -64,32 +68,51 @@ namespace Jellyfin.LiveTv.Listings string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv"); string cacheFile = Path.Join(cacheDir, cacheFilename); - if (File.Exists(cacheFile)) + if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) { - if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) + return cacheFile; + } + + var isRemote = info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase); + + if (isRemote + && _lastDownloadFailures.TryGetValue(info.Path, out var lastFailure) + && DateTime.UtcNow - lastFailure < _maxCacheAge) + { + if (File.Exists(cacheFile)) { return cacheFile; } - File.Delete(cacheFile); - } - else - { - Directory.CreateDirectory(cacheDir); + throw new InvalidOperationException("Skipping the XMLTV download after a recent failure: " + info.Path); } + Directory.CreateDirectory(cacheDir); + + var tempFile = cacheFile + ".tmp"; + try { - if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + using var timeout = new CancellationTokenSource(_downloadTimeout); + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + var downloadCancellationToken = linkedTokenSource.Token; + + if (isRemote) { _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + httpClient.Timeout = _downloadTimeout; + + using var response = await httpClient + .GetAsync(info.Path, HttpCompletionOption.ResponseHeadersRead, downloadCancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path; - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(downloadCancellationToken).ConfigureAwait(false); await using (stream.ConfigureAwait(false)) { - return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false); + await UnzipIfNeededAndCopy(redirectedUrl, stream, tempFile, downloadCancellationToken).ConfigureAwait(false); } } else @@ -97,28 +120,63 @@ namespace Jellyfin.LiveTv.Listings var stream = AsyncFile.OpenRead(info.Path); await using (stream.ConfigureAwait(false)) { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + await UnzipIfNeededAndCopy(info.Path, stream, tempFile, downloadCancellationToken).ConfigureAwait(false); } } + + File.Move(tempFile, cacheFile, true); + _lastDownloadFailures.TryRemove(info.Path, out _); + + return cacheFile; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TryDeleteTempFile(tempFile); + + throw; } catch (Exception ex) { + TryDeleteTempFile(tempFile); + _lastDownloadFailures[info.Path] = DateTime.UtcNow; + _logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path); if (File.Exists(cacheFile)) { - File.Delete(cacheFile); + _logger.LogWarning("Falling back to the previously downloaded XMLTV file for {Path}", info.Path); + + return cacheFile; + } + + if (ex is OperationCanceledException) + { + throw new TimeoutException( + string.Format(CultureInfo.InvariantCulture, "Timed out downloading the XMLTV file from {0}", info.Path), + ex); } throw; } } - private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) + private void TryDeleteTempFile(string tempFile) + { + try + { + File.Delete(tempFile); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Error deleting temporary XMLTV file {File}", tempFile); + } + } + + private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) { var fileStream = new FileStream( file, - FileMode.CreateNew, + FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, @@ -148,15 +206,8 @@ namespace Jellyfin.LiveTv.Listings var fileInfo = new FileInfo(file); if (!fileInfo.Exists || fileInfo.Length == 0) { - if (fileInfo.Exists) - { - File.Delete(file); - } - throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl); } - - return file; } public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) @@ -173,7 +224,29 @@ namespace Jellyfin.LiveTv.Listings var reader = new XmlTvReader(path, GetLanguage(info)); return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) - .Select(p => GetProgramInfo(p, info)); + .Select(p => GetProgramInfoWithEtag(p, info)); + } + + private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info) + { + var programInfo = GetProgramInfo(program, info); + + if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason)) + { + programInfo.Etag = etag; + } + else + { + _logger.LogDebug( + "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate} to {EndDate}: {Reason}. The program will be treated as updated on each guide refresh.", + programInfo.Id, + programInfo.ChannelId, + programInfo.StartDate, + programInfo.EndDate, + reason); + } + + return programInfo; } private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) @@ -259,6 +332,13 @@ namespace Jellyfin.LiveTv.Listings public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) { + // Saving the provider is an explicit retry, so the download backoff has to be dropped + // together with the cached file the listings manager deletes. + if (!string.IsNullOrEmpty(info.Path)) + { + _lastDownloadFailures.TryRemove(info.Path, out _); + } + // Assume all urls are valid. check files for existence if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path)) { diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs new file mode 100644 index 0000000000..b5ddb1530f --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs @@ -0,0 +1,184 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.Listings +{ + internal static class XmlTvProgramEtag + { + internal const string Prefix = "xmltv-sha256-v1:"; + + internal static bool IsXmlTvEtag(string? etag) + => !string.IsNullOrWhiteSpace(etag) + && etag.StartsWith(Prefix, StringComparison.Ordinal); + + // Returns true only when the incoming etag is XMLTV-style AND equals the stored value. + // The IsXmlTvEtag gate keeps other providers (e.g. Schedules Direct) on the + // field-by-field update path even if their etag strings happen to match. + internal static bool MatchesStored(string? incomingEtag, string? storedEtag) + => IsXmlTvEtag(incomingEtag) + && string.Equals(incomingEtag, storedEtag, StringComparison.OrdinalIgnoreCase); + + internal static bool TryCreate(ProgramInfo programInfo, out string? etag, out string? reason) + { + etag = null; + + if (string.IsNullOrWhiteSpace(programInfo.Id)) + { + reason = "program id is empty"; + return false; + } + + if (string.IsNullOrWhiteSpace(programInfo.ChannelId)) + { + reason = "channel id is empty"; + return false; + } + + if (programInfo.StartDate == default) + { + reason = "start date is empty"; + return false; + } + + if (programInfo.EndDate == default) + { + reason = "end date is empty"; + return false; + } + + if (programInfo.EndDate <= programInfo.StartDate) + { + reason = "end date is not after start date"; + return false; + } + + var builder = new StringBuilder(1024); + + // Keep this list aligned with the ProgramInfo fields consumed by GuideManager. + AppendValue(builder, "schema", "xmltv-programinfo-v1"); + AppendValue(builder, nameof(programInfo.Id), programInfo.Id); + AppendValue(builder, nameof(programInfo.ChannelId), programInfo.ChannelId); + AppendValue(builder, nameof(programInfo.Name), programInfo.Name); + AppendValue(builder, nameof(programInfo.OfficialRating), programInfo.OfficialRating); + AppendValue(builder, nameof(programInfo.Overview), programInfo.Overview); + AppendValue(builder, nameof(programInfo.StartDate), programInfo.StartDate); + AppendValue(builder, nameof(programInfo.EndDate), programInfo.EndDate); + AppendList(builder, nameof(programInfo.Genres), programInfo.Genres); + AppendValue(builder, nameof(programInfo.OriginalAirDate), programInfo.OriginalAirDate); + AppendValue(builder, nameof(programInfo.IsHD), programInfo.IsHD); + AppendValue(builder, nameof(programInfo.Audio), programInfo.Audio?.ToString()); + AppendValue(builder, nameof(programInfo.CommunityRating), programInfo.CommunityRating); + AppendValue(builder, nameof(programInfo.IsRepeat), programInfo.IsRepeat); + AppendValue(builder, nameof(programInfo.EpisodeTitle), programInfo.EpisodeTitle); + AppendValue(builder, nameof(programInfo.ImagePath), programInfo.ImagePath); + AppendValue(builder, nameof(programInfo.ImageUrl), programInfo.ImageUrl); + AppendValue(builder, nameof(programInfo.ThumbImageUrl), programInfo.ThumbImageUrl); + AppendValue(builder, nameof(programInfo.LogoImageUrl), programInfo.LogoImageUrl); + AppendValue(builder, nameof(programInfo.BackdropImageUrl), programInfo.BackdropImageUrl); + AppendValue(builder, nameof(programInfo.IsMovie), programInfo.IsMovie); + AppendValue(builder, nameof(programInfo.IsSports), programInfo.IsSports); + AppendValue(builder, nameof(programInfo.IsSeries), programInfo.IsSeries); + AppendValue(builder, nameof(programInfo.IsLive), programInfo.IsLive); + AppendValue(builder, nameof(programInfo.IsNews), programInfo.IsNews); + AppendValue(builder, nameof(programInfo.IsKids), programInfo.IsKids); + AppendValue(builder, nameof(programInfo.IsPremiere), programInfo.IsPremiere); + AppendValue(builder, nameof(programInfo.ProductionYear), programInfo.ProductionYear); + AppendValue(builder, nameof(programInfo.SeriesId), programInfo.SeriesId); + AppendValue(builder, nameof(programInfo.ShowId), programInfo.ShowId); + AppendValue(builder, nameof(programInfo.SeasonNumber), programInfo.SeasonNumber); + AppendValue(builder, nameof(programInfo.EpisodeNumber), programInfo.EpisodeNumber); + AppendDictionary(builder, nameof(programInfo.ProviderIds), programInfo.ProviderIds); + AppendDictionary(builder, nameof(programInfo.SeriesProviderIds), programInfo.SeriesProviderIds); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + etag = Prefix + Convert.ToHexString(hash); + reason = null; + return true; + } + + private static void AppendValue(StringBuilder builder, string name, string? value) + { + builder.Append(name).Append('|'); + if (value is null) + { + builder.Append('N').Append("|0|"); + } + else + { + builder.Append('S') + .Append('|') + .Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append('|') + .Append(value); + } + + builder.Append('\n'); + } + + private static void AppendValue(StringBuilder builder, string name, DateTime value) + => AppendValue(builder, name, FormatDateTime(value)); + + private static void AppendValue(StringBuilder builder, string name, DateTime? value) + => AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null); + + private static void AppendValue(StringBuilder builder, string name, bool value) + => AppendValue(builder, name, value ? "true" : "false"); + + private static void AppendValue(StringBuilder builder, string name, bool? value) + => AppendValue(builder, name, value switch { true => "true", false => "false", null => null }); + + private static void AppendValue(StringBuilder builder, string name, int? value) + => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture)); + + private static void AppendValue(StringBuilder builder, string name, float? value) + => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture)); + + // Treat Unspecified as UTC so the etag does not vary with the server's local timezone. + private static string FormatDateTime(DateTime value) + { + var utc = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc), + _ => value.ToUniversalTime(), + }; + + return utc.ToString("O", CultureInfo.InvariantCulture); + } + + private static void AppendList(StringBuilder builder, string name, IReadOnlyList<string> values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + for (var i = 0; i < values.Count; i++) + { + AppendValue(builder, $"{name}[{i}]", values[i]); + } + } + + private static void AppendDictionary(StringBuilder builder, string name, IReadOnlyDictionary<string, string?> values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + if (values.Count == 0) + { + return; + } + + var index = 0; + foreach (var (key, value) in values + .OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Key, StringComparer.Ordinal)) + { + AppendValue(builder, $"{name}[{index}].Key", key); + AppendValue(builder, $"{name}[{index}].Value", value); + index++; + } + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs deleted file mode 100644 index a590193b5f..0000000000 --- a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs +++ /dev/null @@ -1,33 +0,0 @@ -using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Entities; - -namespace Jellyfin.LiveTv; - -/// <summary> -/// Helpers for keeping Live TV channel icons in sync with guide data. -/// </summary> -internal static class LiveTvChannelImageHelper -{ - /// <summary> - /// Applies the channel icon from guide or tuner metadata. - /// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged. - /// </summary> - /// <param name="item">The channel item.</param> - /// <param name="imagePath">The local image path from the tuner, if any.</param> - /// <param name="imageUrl">The remote image URL from the guide provider, if any.</param> - /// <returns><c>true</c> when the item image metadata was updated.</returns> - internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl) - { - var newImageSource = !string.IsNullOrWhiteSpace(imagePath) - ? imagePath - : imageUrl; - - if (string.IsNullOrWhiteSpace(newImageSource)) - { - return false; - } - - item.SetImagePath(ImageType.Primary, newImageSource); - return true; - } -} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 173d3c3e8e..93f9083178 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -1229,6 +1229,14 @@ namespace Jellyfin.LiveTv .Where(IsLiveTvEnabled); } + /// <inheritdoc /> + public bool IsEnabledForUser(User user) + { + ArgumentNullException.ThrowIfNull(user); + + return IsLiveTvEnabled(user); + } + /// <summary> /// Resets the tuner. /// </summary> @@ -1262,7 +1270,7 @@ namespace Jellyfin.LiveTv public Folder GetInternalLiveTvFolder(CancellationToken cancellationToken) { - var name = _localization.GetLocalizedString("HeaderLiveTV"); + var name = _localization.GetServerLocalizedString("HeaderLiveTV"); return _libraryManager.GetNamedView(name, CollectionType.livetv, name); } diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs index 846f9baf71..8a5aeccba5 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs @@ -286,7 +286,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + _libraryManager.QueueLibraryScan(); } } @@ -497,7 +497,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable // trim trailing period from the folder name var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); - if (metadata is not null && metadata.ProductionYear.HasValue) + if (metadata is not null && metadata.ProductionYear is not null) { folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; } @@ -532,7 +532,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable } var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) + if (timer.ProductionYear is not null) { folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; } @@ -550,7 +550,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable } var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) + if (timer.ProductionYear is not null) { folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; } diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs index 7e68dbb547..e0e5a00fb9 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs @@ -290,7 +290,7 @@ public class RecordingsMetadataManager null, DateTime.UtcNow.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); - if (item.ProductionYear.HasValue) + if (item.ProductionYear is not null) { await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); } diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index e1f87a7bd4..15b1368939 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -326,7 +326,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun BufferMs = 0, Container = "ts", Id = id, - SupportsDirectPlay = false, + SupportsDirectPlay = true, SupportsDirectStream = true, SupportsTranscoding = true, IsInfiniteStream = true, diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs index fb606be0ef..902ca76af8 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs @@ -32,6 +32,7 @@ namespace Jellyfin.LiveTv.TunerHosts { private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"]; private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"]; + private static readonly string[] _manifestExtensions = [".m3u8", ".m3u", ".mpd"]; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; @@ -151,11 +152,20 @@ namespace Jellyfin.LiveTv.TunerHosts var protocol = _mediaSourceManager.GetPathProtocol(path); var isRemote = true; - if (Uri.TryCreate(path, UriKind.Absolute, out var uri)) + Uri.TryCreate(path, UriKind.Absolute, out var uri); + if (uri is not null) { isRemote = !_networkManager.IsInLocalNetwork(uri.Host); } + // A manifest is not a byte stream. Serving one directly hands the client a playlist whose + // variant and segment URIs are relative to the origin, and those do not resolve against the + // Jellyfin url the client fetched it from. Remux or transcode these instead. + if (IsManifest(path, uri)) + { + supportsDirectPlay = false; + } + var httpHeaders = new Dictionary<string, string>(); if (protocol == MediaProtocol.Http) @@ -210,6 +220,20 @@ namespace Jellyfin.LiveTv.TunerHosts return mediaSource; } + /// <summary> + /// Determines whether a channel path points at an HLS or DASH manifest rather than at a byte stream. + /// </summary> + /// <param name="path">The channel path.</param> + /// <param name="uri">The channel path parsed as an absolute uri, or <c>null</c> if it is not one.</param> + /// <returns><c>true</c> if the path names a streaming manifest.</returns> + private static bool IsManifest(string path, Uri uri) + { + // Use the uri path when there is one so that a query string does not hide the extension. + var extension = Path.GetExtension(uri is null ? path : uri.AbsolutePath); + + return _manifestExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase); + } + public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken) { return Task.FromResult(new List<TunerHostInfo>()); diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index 5e7e2090cd..55068a5e7e 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -2,13 +2,17 @@ <PropertyGroup> <TargetFramework>net10.0</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> + <ItemGroup> + <Compile Include="..\..\SharedVersion.cs" /> + </ItemGroup> + <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.MediaEncoding.Keyframes</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> @@ -22,10 +26,4 @@ <PackageReference Include="NEbml" /> </ItemGroup> - <ItemGroup> - <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> - <_Parameter1>Jellyfin.MediaEncoding.Keyframes.Tests</_Parameter1> - </AssemblyAttribute> - </ItemGroup> - </Project> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Properties/AssemblyInfo.cs b/src/Jellyfin.MediaEncoding.Keyframes/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..70cc8a4648 --- /dev/null +++ b/src/Jellyfin.MediaEncoding.Keyframes/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Jellyfin.MediaEncoding.Keyframes.Tests")] diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 4559f68ce8..496c108cfd 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -491,6 +491,7 @@ public class NetworkManager : INetworkManager, IDisposable startupOverrideKey, true, true)); + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; return; } @@ -580,10 +581,53 @@ public class NetworkManager : INetworkManager, IDisposable } } + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; } } + /// <summary> + /// Warns when a full-URL published server override uses a public path that differs from the configured base + /// URL. Jellyfin appends the base URL to generated Live TV client URLs in this case, which can conflict with + /// reverse proxies that translate public request paths. Bare host/IP overrides are exempt because the base URL + /// is appended when the API URL is built from them. + /// </summary> + /// <param name="publishedServerUrls">The parsed published server URL overrides.</param> + /// <param name="baseUrl">The configured base URL, if any.</param> + private void WarnIfPublishedUrlBasePathDiffers(List<PublishedServerUriOverride> publishedServerUrls, string baseUrl) + { + if (string.IsNullOrEmpty(baseUrl)) + { + return; + } + + foreach (var overrideUri in publishedServerUrls.Select(x => x.OverrideUri).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!overrideUri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !overrideUri.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!Uri.TryCreate(overrideUri, UriKind.Absolute, out var uri)) + { + continue; + } + + var path = Uri.UnescapeDataString(uri.AbsolutePath).TrimEnd('/'); + if (path.EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var publishedServerHost = uri.GetComponents(UriComponents.HostAndPort, UriFormat.Unescaped); + _logger.LogWarning( + "The published server URL for host '{PublishedServerHost}' does not end with the configured base URL '{BaseUrl}'. Jellyfin will append this base URL when generating Live TV client URLs. If your reverse proxy translates public paths, this may cause Live TV playback to fail. Update the Published Server URIs setting on the Networking page of the admin dashboard, the JELLYFIN_PublishedServerUrl environment variable / --published-server-url option, or the reverse proxy path mapping accordingly.", + publishedServerHost, + baseUrl); + } + } + private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) { if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) @@ -851,7 +895,7 @@ public class NetworkManager : INetworkManager, IDisposable bool isExternal = !IsInLocalNetwork(source); _logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal); - if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result)) + if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result, out port)) { return result; } @@ -1017,11 +1061,12 @@ public class NetworkManager : INetworkManager, IDisposable /// <param name="source">IP source address to use.</param> /// <param name="isInExternalSubnet">True if the source is in an external subnet.</param> /// <param name="bindPreference">The published server URL that matches the source address.</param> + /// <param name="port">The explicit port parsed from the override, if any.</param> /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> - private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference) + private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference, out int? port) { bindPreference = string.Empty; - int? port = null; + port = null; // Only consider subnets including the source IP, preferring specific overrides List<PublishedServerUriOverride> validPublishedServerUrls; @@ -1063,25 +1108,43 @@ public class NetworkManager : INetworkManager, IDisposable return false; } - // Handle override specifying port - var parts = bindPreference.Split(':'); - if (parts.Length > 1) + // Handle override specifying an explicit port. + (bindPreference, port) = ParseHostAndPort(bindPreference); + + if (port.HasValue) { - if (int.TryParse(parts[1], out int p)) - { - bindPreference = parts[0]; - port = p; - _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); - return true; - } + _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); + } + else + { + _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); } - - _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); return true; } /// <summary> + /// Splits a published server URL override into its host and explicit port, if any. + /// Full URLs (containing "://") are returned whole, with any port left embedded. + /// </summary> + /// <param name="value">The override value, e.g. "host:port", "[::1]:port", or a full URL.</param> + /// <returns>The parsed host (or the original value if not split) and the explicit port, if any.</returns> + private static (string Host, int? Port) ParseHostAndPort(string value) + { + if (value.Contains("://", StringComparison.Ordinal)) + { + return (value, null); + } + + if (Uri.TryCreate("any://" + value, UriKind.Absolute, out var parsed) && parsed.Port != -1) + { + return (parsed.DnsSafeHost, parsed.Port); + } + + return (value, null); + } + + /// <summary> /// Attempts to match the source against the user defined bind interfaces. /// </summary> /// <param name="source">IP source address to use.</param> diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs index 1f06e8fde6..5f5f273f12 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs @@ -1,5 +1,9 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Controllers; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Api.Tests.Controllers @@ -41,5 +45,77 @@ namespace Jellyfin.Api.Tests.Controllers return data; } + + [Fact] + public async Task WaitForActiveTranscodingRequests_WaitsUntilRequestCompletes() + { + var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance) + { + ActiveRequestCount = 1 + }; + + var waitTask = DynamicHlsController.WaitForActiveTranscodingRequests(job, CancellationToken.None); + Assert.False(waitTask.IsCompleted); + + job.DecrementActiveRequestCount(); + + await waitTask; + } + + [Fact] + public async Task WaitForActiveTranscodingRequests_WaitsForEveryRequest() + { + var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance) + { + ActiveRequestCount = 2 + }; + + var waitTask = DynamicHlsController.WaitForActiveTranscodingRequests(job, CancellationToken.None); + job.DecrementActiveRequestCount(); + + await Task.Delay(150, TestContext.Current.CancellationToken); + Assert.False(waitTask.IsCompleted); + + job.DecrementActiveRequestCount(); + + await waitTask; + } + + [Fact] + public async Task WaitForActiveTranscodingRequests_ReturnsWithoutAnActiveRequest() + { + var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance); + + await DynamicHlsController.WaitForActiveTranscodingRequests(job, CancellationToken.None); + await DynamicHlsController.WaitForActiveTranscodingRequests(null, CancellationToken.None); + } + + [Fact] + public async Task WaitForActiveTranscodingRequests_ObservesCancellation() + { + var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance) + { + ActiveRequestCount = 1 + }; + using var cancellationTokenSource = new CancellationTokenSource(); + + var waitTask = DynamicHlsController.WaitForActiveTranscodingRequests(job, cancellationTokenSource.Token); + await cancellationTokenSource.CancelAsync(); + + await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask); + } + + [Fact] + public async Task ActiveRequestCount_UpdatesAtomically() + { + const int RequestCount = 1000; + var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance); + + await Task.WhenAll( + Task.Run(() => Parallel.For(0, RequestCount, _ => job.IncrementActiveRequestCount())), + Task.Run(() => Parallel.For(0, RequestCount, _ => job.DecrementActiveRequestCount()))); + + Assert.Equal(0, job.ActiveRequestCount); + } } } diff --git a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs new file mode 100644 index 0000000000..1a91efe4f2 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.Api.Controllers; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public class ItemUpdateControllerTests +{ + private readonly ItemUpdateController _subject; + + public ItemUpdateControllerTests() + { + _subject = new ItemUpdateController( + Mock.Of<IFileSystem>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IProviderManager>(), + Mock.Of<ILocalizationManager>(), + Mock.Of<IServerConfigurationManager>()); + } + + [Fact] + public async Task UpdateItem_WhenOnlyTagsFieldSupplied_DoesNotThrowAndAppliesTags() + { + // Regression test for https://github.com/jellyfin/jellyfin/issues/17366 + // A partial update payload that only sets "Tags" leaves every other + // BaseItemDto collection property null (they have no default + // initializer). Genres and ProviderIds used to be fed straight into + // Distinct()/ToList() without a null check, so this call used to throw + // ArgumentNullException before the fix below was applied. + var movie = new Movie(); + var request = new BaseItemDto + { + Tags = new[] { "new-tag-1", "new-tag-2" } + }; + + await InvokeUpdateItem(request, movie); + + Assert.Equal(new[] { "new-tag-1", "new-tag-2" }, movie.Tags); + Assert.Empty(movie.Genres); + Assert.Empty(movie.ProviderIds); + } + + [Fact] + public async Task UpdateItem_WhenGenresAndProviderIdsOmitted_LeavesExistingValuesUnchanged() + { + var movie = new Movie + { + Genres = new[] { "Action" } + }; + movie.ProviderIds["Imdb"] = "tt1234567"; + + var request = new BaseItemDto + { + Tags = Array.Empty<string>() + }; + + await InvokeUpdateItem(request, movie); + + Assert.Equal(new[] { "Action" }, movie.Genres); + Assert.Equal("tt1234567", movie.ProviderIds["Imdb"]); + } + + private Task InvokeUpdateItem(BaseItemDto request, BaseItem item) + { + return _subject.UpdateItem(request, item); + } +} diff --git a/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs new file mode 100644 index 0000000000..bad11a9257 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs @@ -0,0 +1,70 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Controllers; +using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public class StartupControllerTests +{ + private readonly StartupController _subject; + private readonly Mock<IUserManager> _mockUserManager; + private readonly Mock<IServerConfigurationManager> _mockConfig; + + public StartupControllerTests() + { + _mockUserManager = new Mock<IUserManager>(); + _mockConfig = new Mock<IServerConfigurationManager>(); + _subject = new StartupController(_mockConfig.Object, _mockUserManager.Object); + } + + private static User CreateUser() + => new User( + "jellyfin", + typeof(DefaultAuthenticationProvider).FullName!, + typeof(DefaultPasswordResetProvider).FullName!); + + [Fact] + public async Task UpdateStartupUser_WhenNoUserExists_ReturnsNotFound() + { + _mockUserManager.Setup(m => m.GetFirstUser()).Returns((User?)null); + + var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "admin", Password = "pw" }); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public async Task UpdateStartupUser_WhenPasswordAlreadyConfigured_ReturnsForbidden() + { + var user = CreateUser(); + user.Password = "already-set-hash"; + _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user); + + var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "attacker", Password = "new-pw" }); + + // The startup wizard must never overwrite the password of an already-provisioned + // account, even if IsStartupWizardCompleted has been cleared. + Assert.IsType<ForbidResult>(result); + _mockUserManager.Verify(m => m.ChangePassword(It.IsAny<System.Guid>(), It.IsAny<string>()), Times.Never); + } + + [Fact] + public async Task UpdateStartupUser_WhenNoPasswordYet_SetsPassword() + { + var user = CreateUser(); + Assert.True(string.IsNullOrEmpty(user.Password)); + _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user); + + var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "jellyfin", Password = "first-pw" }); + + Assert.IsType<NoContentResult>(result); + _mockUserManager.Verify(m => m.ChangePassword(user.Id, "first-pw"), Times.Once); + } +} diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs index a003be4d96..fe824eddd9 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -1,13 +1,21 @@ using System; using System.Globalization; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Helpers; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.MediaInfo; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; using Xunit; @@ -16,17 +24,28 @@ namespace Jellyfin.Api.Tests.Helpers { public class MediaInfoHelperTests { - private static MediaInfoHelper CreateHelper() + private const string LiveStreamFilesPath = "/LiveTv/LiveStreamFiles/abc/stream.ts"; + + private static MediaInfoHelper CreateHelper( + IMediaSourceManager? mediaSourceManager = null, + IServerApplicationHost? appHost = null, + string baseUrl = "") { + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager + .Setup(x => x.GetConfiguration(It.IsAny<string>())) + .Returns(new NetworkConfiguration { BaseUrl = baseUrl }); + return new MediaInfoHelper( Mock.Of<IUserManager>(), Mock.Of<ILibraryManager>(), - Mock.Of<IMediaSourceManager>(), + mediaSourceManager ?? Mock.Of<IMediaSourceManager>(), Mock.Of<IMediaEncoder>(), - Mock.Of<IServerConfigurationManager>(), + serverConfigurationManager.Object, Mock.Of<ILogger<MediaInfoHelper>>(), Mock.Of<INetworkManager>(), - Mock.Of<IDeviceManager>()); + Mock.Of<IDeviceManager>(), + appHost ?? Mock.Of<IServerApplicationHost>()); } private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true) @@ -95,5 +114,403 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(directPlay.Id, result.MediaSources[0].Id); } + + [Fact] + public async Task GetPlaybackInfo_ExistingLiveStream_RewritesReturnedCloneOnly() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(sharedLiveSource); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var result = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>(), liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, result.MediaSources[0].Path); + + // The shared instance handed back by GetLiveStream must remain untouched; only the clone in the response may be rewritten. + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task OpenMediaSource_RewritesReturnedLiveStreamPath() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://127.0.0.1:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://public.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://public.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ExternalDockerBridgeBehindReverseProxy_UsesPublishedUrl() + { + const string LocalPath = "http://172.23.0.5:8096" + LiveStreamFilesPath; + + // Represents the instance MediaSourceManager keeps for its own bookkeeping; the helper never sees it + // and must not be able to affect it. + var localSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + // Mirrors production: MediaSourceManager.OpenLiveStream hands back its own instance, so what the + // helper mutates must be a deserialized copy, never localSource itself. + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(localSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://jellyfin.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://jellyfin.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + + // The mock now actually derives its response from localSource, so this assertion is meaningful: + // rewriting the returned clone must never mutate the object localSource represents. + Assert.Equal(LocalPath, localSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ForeignHostWithLiveStreamFilesRoute_PathUnchanged() + { + // A plugin or remote source can expose a path that happens to match the /LiveTv/LiveStreamFiles/ + // route shape without actually being hosted by this server. Only opened streams (which always + // carry a LiveStreamId) are eligible for rewriting. + const string ForeignPath = "https://other-server:8096" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = ForeignPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(ForeignPath, response.MediaSource.Path); + } + + [Theory] + [InlineData(MediaProtocol.Http, "http://192.168.1.50:5004/live/channel1.ts")] + [InlineData(MediaProtocol.File, "/media/livetv/buffer/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/Videos/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/library/movie.strm")] + public async Task OpenMediaSource_NotAPublishableLiveStreamFilesPath_PathUnchanged(MediaProtocol protocol, string path) + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = protocol, + Path = path + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(path, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlConfigured_RewritesWithBaseUrlPrefix() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://media.example.com/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlSegmentMismatch_PathUnchanged() + { + const string LocalPath = "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(LocalPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ExplicitPortOverrideWithBaseUrl_RewritesToOverrideHostAndPort() + { + // Mirrors NetworkManager.GetBindAddress resolving a "internal=myhost:8097" override: the smart API + // URL carries an explicit non-default port alongside the configured BaseUrl. + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "http://myhost:8097/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("http://myhost:8097/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_TwoRequestsForSharedLiveStream_ReceiveIndependentSmartApiBases() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + // Both requests resolve the same live stream; the manager hands back its own shared instance each time. + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(sharedLiveSource); + + var requestA = new DefaultHttpContext().Request; + var requestB = new DefaultHttpContext().Request; + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(requestA)).Returns("https://a.example.com"); + appHost.Setup(x => x.GetSmartApiUrl(requestB)).Returns("https://b.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var resultA = await helper.GetPlaybackInfo(new Movie(), null, requestA, liveStreamId: "live-1").ConfigureAwait(true); + var resultB = await helper.GetPlaybackInfo(new Movie(), null, requestB, liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://a.example.com" + LiveStreamFilesPath, resultA.MediaSources[0].Path); + Assert.Equal("https://b.example.com" + LiveStreamFilesPath, resultB.MediaSources[0].Path); + + // Neither request's rewrite may leak into the other's response or into the shared instance. + Assert.NotEqual(resultA.MediaSources[0].Path, resultB.MediaSources[0].Path); + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_AutoOpenLiveStreamFlow_MergedOpenedSourceHasRewrittenPath() + { + // Reproduces MediaInfoController.GetPostedPlaybackInfo's AutoOpenLiveStream branch (~line 220-246): + // it picks the RequiresOpening source out of GetPlaybackInfo's result, calls OpenMediaSource, then + // merges by replacing result.MediaSources with the opened source. Building a full controller fixture + // is impractical (it pulls in many unrelated dependencies), so this test drives the same two helper + // calls the controller makes and asserts the merged source is the rewritten one. + var itemId = Guid.NewGuid(); + var sourceId = itemId.ToString("N", CultureInfo.InvariantCulture); + + // The pre-open placeholder source carries a different local path than the one OpenMediaSource + // eventually returns, so the final assertion can prove the merge picked up the freshly opened + // source rather than the stale placeholder. + var requiresOpeningSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/LiveTv/LiveStreamFiles/placeholder/stream.ts", + RequiresOpening = true, + LiveStreamId = string.Empty + }; + + var openedSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.GetPlaybackMediaSources(It.IsAny<BaseItem>(), It.IsAny<User>(), true, true, It.IsAny<CancellationToken>())) + .ReturnsAsync(new[] { requiresOpeningSource }); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + // MediaSourceManager.OpenLiveStream JSON-clones its internal MediaSourceInfo before returning + // it (see Emby.Server.Implementations/Library/MediaSourceManager.cs:693-706); mirror that so + // the in-place rewrite below can't be observed on openedSource itself. + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(openedSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var info = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>()).ConfigureAwait(true); + + var mediaSource = info.MediaSources[0]; + Assert.True(mediaSource.RequiresOpening); + var preOpenPath = mediaSource.Path; + + var openStreamResult = await helper.OpenMediaSource( + new DefaultHttpContext(), + new LiveStreamRequest { OpenToken = mediaSource.OpenToken, ItemId = itemId }).ConfigureAwait(true); + + // MediaInfoController.cs:245 - info.MediaSources = new[] { openStreamResult.MediaSource }; + info.MediaSources = new[] { openStreamResult.MediaSource }; + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, info.MediaSources[0].Path); + Assert.NotEqual(preOpenPath, info.MediaSources[0].Path); + + // The pristine OpenLiveStream response object must remain unrewritten; only the merged clone changed. + Assert.Equal("http://172.19.0.3:8096" + LiveStreamFilesPath, openedSource.Path); + } + + [Theory] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "?token=1", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath + "?token=1")] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "#fragment", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "https://172.19.0.3:8920" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://192.168.1.10:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com:8920", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com:8920" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://jellyfin/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin/", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + null)] + [InlineData( + "https://media.example.com", + "/media/livetv/buffer/abc/stream.ts", + MediaProtocol.File, + "", + null)] + [InlineData( + "https://media.example.com", + "not a uri", + MediaProtocol.Http, + "", + null)] + public void GetPublishedLiveStreamPath_VariousInputs_ReturnsExpected(string smartApiUrl, string localPath, MediaProtocol protocol, string baseUrl, string? expected) + { + var result = MediaInfoHelper.GetPublishedLiveStreamPath(smartApiUrl, localPath, protocol, baseUrl); + + Assert.Equal(expected, result); + } + + private static MediaInfoHelper CreateOpenMediaSourceHelper(MediaSourceInfo mediaSource, string smartApiUrl, string baseUrl = "") + { + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new LiveStreamResponse(mediaSource)); + + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns(smartApiUrl); + + return CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object, baseUrl: baseUrl); + } } } diff --git a/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs new file mode 100644 index 0000000000..5132e529dd --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.ClientEvent; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests +{ + public class ClientEventLoggerTests + { + [Theory] + [InlineData("../../../../etc/passwd", "1.0")] + [InlineData("..\\..\\windows\\system32", "1.0")] + [InlineData("normal-client", "../../../etc/passwd")] + [InlineData("/absolute/path", "1.0")] + public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion) + { + var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName()); + Directory.CreateDirectory(logDir); + try + { + var paths = new Mock<IServerApplicationPaths>(); + paths.Setup(p => p.LogDirectoryPath).Returns(logDir); + + var logger = new ClientEventLogger(paths.Object); + using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload")); + + var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents); + + var resolved = Path.GetFullPath(Path.Combine(logDir, fileName)); + var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar; + Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal); + Assert.True(File.Exists(resolved)); + } + finally + { + Directory.Delete(logDir, recursive: true); + } + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs index 1f59908a86..e57fbfe473 100644 --- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs +++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.IO; using System.Linq; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -8,29 +10,31 @@ namespace Jellyfin.Controller.Tests { public class DirectoryServiceTests { - private const string LowerCasePath = "/music/someartist"; - private const string UpperCasePath = "/music/SOMEARTIST"; + // Path.GetDirectoryName, which Invalidate uses to find the parent, normalizes the + // separators, so cache keys only match the parent it returns when they use the platform's. + private static readonly string _lowerCasePath = LocalPath("/music/someartist"); + private static readonly string _upperCasePath = LocalPath("/music/SOMEARTIST"); private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata = { new() { - FullName = LowerCasePath + "/Artwork", + FullName = Path.Combine(_lowerCasePath, "Artwork"), IsDirectory = true }, new() { - FullName = LowerCasePath + "/Some Other Folder", + FullName = Path.Combine(_lowerCasePath, "Some Other Folder"), IsDirectory = true }, new() { - FullName = LowerCasePath + "/Song 2.mp3", + FullName = Path.Combine(_lowerCasePath, "Song 2.mp3"), IsDirectory = false }, new() { - FullName = LowerCasePath + "/Song 3.mp3", + FullName = Path.Combine(_lowerCasePath, "Song 3.mp3"), IsDirectory = false } }; @@ -39,12 +43,12 @@ namespace Jellyfin.Controller.Tests { new() { - FullName = UpperCasePath + "/Lyrics", + FullName = Path.Combine(_upperCasePath, "Lyrics"), IsDirectory = true }, new() { - FullName = UpperCasePath + "/Song 1.mp3", + FullName = Path.Combine(_upperCasePath, "Song 1.mp3"), IsDirectory = false } }; @@ -53,12 +57,12 @@ namespace Jellyfin.Controller.Tests public void GetFileSystemEntries_GivenPathsWithDifferentCasing_CachesAll() { var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); - var upperCaseResult = directoryService.GetFileSystemEntries(UpperCasePath); - var lowerCaseResult = directoryService.GetFileSystemEntries(LowerCasePath); + var upperCaseResult = directoryService.GetFileSystemEntries(_upperCasePath); + var lowerCaseResult = directoryService.GetFileSystemEntries(_lowerCasePath); Assert.Equal(_upperCaseFileSystemMetadata, upperCaseResult); Assert.Equal(_lowerCaseFileSystemMetadata, lowerCaseResult); @@ -68,12 +72,12 @@ namespace Jellyfin.Controller.Tests public void GetFiles_GivenPathsWithDifferentCasing_ReturnsCorrectFiles() { var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); - var upperCaseResult = directoryService.GetFiles(UpperCasePath); - var lowerCaseResult = directoryService.GetFiles(LowerCasePath); + var upperCaseResult = directoryService.GetFiles(_upperCasePath); + var lowerCaseResult = directoryService.GetFiles(_lowerCasePath); Assert.Equal(_upperCaseFileSystemMetadata.Where(f => !f.IsDirectory), upperCaseResult); Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => !f.IsDirectory), lowerCaseResult); @@ -83,12 +87,12 @@ namespace Jellyfin.Controller.Tests public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories() { var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); - fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); - var upperCaseResult = directoryService.GetDirectories(UpperCasePath); - var lowerCaseResult = directoryService.GetDirectories(LowerCasePath); + var upperCaseResult = directoryService.GetDirectories(_upperCasePath); + var lowerCaseResult = directoryService.GetDirectories(_lowerCasePath); Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult); Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult); @@ -248,5 +252,171 @@ namespace Jellyfin.Controller.Tests Assert.Equal(cachedPaths, result); Assert.Equal(newPaths, secondResult); } + + [Fact] + public void GetFileSystemEntries_RepeatedPath_ReadsTheFileSystemOnce() + { + var fileSystemMock = new Mock<IFileSystem>(MockBehavior.Strict); + fileSystemMock.Setup(f => f.GetFileSystemEntries(_lowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + directoryService.GetFileSystemEntries(_lowerCasePath); + directoryService.GetFileSystemEntries(_lowerCasePath); + + fileSystemMock.Verify(f => f.GetFileSystemEntries(_lowerCasePath), Times.Once); + } + + [Fact] + public void Invalidate_GivenADirectory_DropsBothTheListingAndTheFilePaths() + { + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata) + .Returns(_upperCaseFileSystemMetadata); + fileSystemMock.SetupSequence(f => f.GetFilePaths(_lowerCasePath, false)) + .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") }) + .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3"), Path.Combine(_lowerCasePath, "Song 2.srt") }); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(_lowerCasePath); + directoryService.GetFilePaths(_lowerCasePath); + + directoryService.Invalidate(_lowerCasePath); + + Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath)); + Assert.Equal(2, directoryService.GetFilePaths(_lowerCasePath).Count); + } + + [Fact] + public void Invalidate_GivenAFile_DropsTheListingOfTheDirectoryHoldingIt() + { + var newFile = Path.Combine(_lowerCasePath, "Song 2.srt"); + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata) + .Returns(_upperCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(_lowerCasePath); + + directoryService.Invalidate(newFile); + + Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath)); + } + + [Fact] + public void GetFilePaths_ClearingTheCache_KeepsTheParentDirectory() + { + var parentPath = LocalPath("/music"); + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFilePaths(_lowerCasePath)) + .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") }); + fileSystemMock.Setup(f => f.GetFileSystemEntries(parentPath)) + .Returns(_lowerCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(parentPath); + + directoryService.GetFilePaths(_lowerCasePath, true); + + directoryService.GetFileSystemEntries(parentPath); + fileSystemMock.Verify(f => f.GetFileSystemEntries(parentPath), Times.Once); + } + + [Fact] + public void GetFileSystemEntries_MoreRecordsThanTheCeiling_DropsCache() + { + // Charged by the files in a listing, not the number of listings, so a few big folders + // reach the limit where a lot of small ones would not. + const int FolderCount = 60; + var bigListing = new FileSystemMetadata[5000]; + for (var i = 0; i < bigListing.Length; i++) + { + bigListing[i] = new FileSystemMetadata + { + FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture), + IsDirectory = false + }; + } + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>())) + .Returns(bigListing); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + const string FirstPath = "/music/artist0"; + directoryService.GetFileSystemEntries(FirstPath); + + for (var i = 1; i < FolderCount; i++) + { + directoryService.GetFileSystemEntries("/music/artist" + i.ToString(CultureInfo.InvariantCulture)); + } + + directoryService.GetFileSystemEntries(FirstPath); + + fileSystemMock.Verify(f => f.GetFileSystemEntries(FirstPath), Times.Exactly(2)); + } + + [Fact] + public void GetFileSystemEntries_RepeatedlyInvalidatedFolder_KeepsUnrelatedEntriesCached() + { + // Invalidating gives the records back, so churning one folder must not add up to the + // ceiling and drop everything else with it. + const int ChurnCount = 50; + var bigListing = new FileSystemMetadata[5000]; + for (var i = 0; i < bigListing.Length; i++) + { + bigListing[i] = new FileSystemMetadata + { + FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture), + IsDirectory = false + }; + } + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>())) + .Returns(bigListing); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + const string ChurnedPath = "/music/watched"; + const string StablePath = "/music/untouched"; + directoryService.GetFileSystemEntries(StablePath); + + for (var i = 0; i < ChurnCount; i++) + { + directoryService.GetFileSystemEntries(ChurnedPath); + directoryService.Invalidate(ChurnedPath); + } + + directoryService.GetFileSystemEntries(StablePath); + + fileSystemMock.Verify(f => f.GetFileSystemEntries(StablePath), Times.Once); + } + + [Fact] + public void GetFileSystemEntry_MissingPath_IsNotRemembered() + { + const string MissingPath = "/music/not-here"; + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemInfo(MissingPath)) + .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = false }) + .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = true }); + + var directoryService = new DirectoryService(fileSystemMock.Object); + + Assert.Null(directoryService.GetFileSystemEntry(MissingPath)); + + Assert.NotNull(directoryService.GetFileSystemEntry(MissingPath)); + } + + private static string LocalPath(string path) + => path.Replace('/', Path.DirectorySeparatorChar); } } diff --git a/tests/Jellyfin.Controller.Tests/Drawing/ImageHelperTests.cs b/tests/Jellyfin.Controller.Tests/Drawing/ImageHelperTests.cs new file mode 100644 index 0000000000..571cb7f0d4 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Drawing/ImageHelperTests.cs @@ -0,0 +1,64 @@ +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Model.Drawing; +using Xunit; + +namespace Jellyfin.Controller.Tests.Drawing; + +public static class ImageHelperTests +{ + [Fact] + public static void GetNewImageSize_ExplicitSizeLargerThanSource_ClampsToSource() + { + // Regression test for https://github.com/jellyfin/jellyfin/issues/17056: the caller-supplied + // width/height were used verbatim, so a single request could ask for a 23100x23100 encode. + var options = new ImageProcessingOptions { Width = 23100, Height = 23100 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(336, newSize.Width); + Assert.Equal(336, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_WidthLargerThanSource_ClampsToSource() + { + var options = new ImageProcessingOptions { Width = 10000 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(600, newSize.Width); + Assert.Equal(336, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_FillLargerThanSource_ClampsToSource() + { + // ResizeFill already refused to upscale; this pins that behaviour. + var options = new ImageProcessingOptions { FillWidth = 23100, FillHeight = 23100 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(600, newSize.Width); + Assert.Equal(336, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_SmallerThanSource_StillDownscales() + { + var options = new ImageProcessingOptions { MaxWidth = 300 }; + + var newSize = ImageHelper.GetNewImageSize(options, new ImageDimensions(600, 336)); + + Assert.Equal(300, newSize.Width); + Assert.Equal(168, newSize.Height); + } + + [Fact] + public static void GetNewImageSize_NoSizeRequested_ReturnsSource() + { + var newSize = ImageHelper.GetNewImageSize(new ImageProcessingOptions(), new ImageDimensions(600, 336)); + + Assert.Equal(600, newSize.Width); + Assert.Equal(336, newSize.Height); + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs new file mode 100644 index 0000000000..272c434fe9 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +[Collection("LibraryManagerTests")] +public class AggregateFolderTests +{ + [Fact] + public void Children_ClearedAfterALibraryWasAdded_ListsTheNewLibrary() + { + var existing = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" }; + var added = new Folder { Id = Guid.NewGuid(), Path = "/libraries/collections" }; + + // What the repository holds grows once the new library has been resolved and stored. + var stored = new List<BaseItem> { existing }; + + var itemRepository = new Mock<IItemRepository>(); + itemRepository.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(() => stored.ToList()); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetItemById(It.IsAny<Guid>())) + .Returns((Guid id) => stored.Find(i => i.Id.Equals(id))); + + BaseItem.ItemRepository = itemRepository.Object; + BaseItem.LibraryManager = libraryManager.Object; + + var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" }; + + Assert.Equal([existing.Id], root.Children.Select(i => i.Id)); + + stored.Add(added); + root.Children = null; + + // Null-forgiving: the setter takes null to mean "drop the cache", the getter reloads. + Assert.Equal([existing.Id, added.Id], root.Children!.Select(i => i.Id)); + } + + [Fact] + public void Children_AssignedASet_KeepsThatSet() + { + var itemRepository = new Mock<IItemRepository>(MockBehavior.Strict); + BaseItem.ItemRepository = itemRepository.Object; + + var assigned = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" }; + var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" }; + + root.Children = [assigned]; + + // Never goes to the repository, so the strict mock stays unused. + Assert.Equal([assigned.Id], root.Children.Select(i => i.Id)); + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index c0a2b0ecca..e072bccb82 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -1,24 +1,141 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; using System.Reflection; +using System.Text; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Querying; using Moq; using Xunit; namespace Jellyfin.Controller.Tests.Entities; +[Collection("LibraryManagerTests")] public class BaseItemTests { [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task ValidateChildren_FailedEnumeration_DoesNotReconcileOrDeleteChildren(bool failAfterFirstChild, bool accessDenied) + { + var previousLibrary = BaseItem.LibraryManager; + var previousRepository = BaseItem.ItemRepository; + var previousLogger = BaseItem.Logger; + var library = new Mock<ILibraryManager>(MockBehavior.Strict); + var repository = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>(MockBehavior.Strict); + var directory = new Mock<IDirectoryService>(); + directory.Setup(d => d.IsAccessible(It.IsAny<string>())).Returns(true); + try + { + BaseItem.LibraryManager = library.Object; + BaseItem.ItemRepository = repository.Object; + BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance; + var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied) + { + Id = Guid.NewGuid(), + Path = "/media/review-folder" + }; + await folder.ValidateChildren(new Progress<double>(), new MetadataRefreshOptions(directory.Object), recursive: false, cancellationToken: TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.True(folder.EnumerationAttempted); + repository.VerifyNoOtherCalls(); + library.VerifyNoOtherCalls(); + } + finally + { + BaseItem.LibraryManager = previousLibrary; + BaseItem.ItemRepository = previousRepository; + BaseItem.Logger = previousLogger; + } + } + + [Fact] + public void SetPrimaryVersionId_Null_RestoresTheItemsOwnPresentationKey() + { + var primaryId = Guid.NewGuid(); + var video = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv" }; + + // While it is a version, it presents as the primary so lists collapse the two together. + video.SetPrimaryVersionId(primaryId); + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey); + + // Promoting it back has to restore its own key, or it keeps collapsing onto - and staying + // hidden behind - a primary it no longer belongs to. + video.SetPrimaryVersionId(null); + Assert.Null(video.PrimaryVersionId); + Assert.Equal(video.Id.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey); + } + + [Fact] + public void GetItemByNameFolderName_ShortName_IsKeptAsIs() + { + SetupPassThroughFileSystem(); + + Assert.Equal("Mairghread Scott", BaseItem.GetItemByNameFolderName("Mairghread Scott.")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_FitsInAPathComponent() + { + SetupPassThroughFileSystem(); + + // What a provider result that concatenated a whole credit list into one name looks like. + var name = string.Join(", ", Enumerable.Repeat("Jerry Siegel (created by: Superman)", 20)); + + var folderName = BaseItem.GetItemByNameFolderName(name); + + Assert.True(Encoding.UTF8.GetByteCount(folderName) <= 128); + Assert.StartsWith("Jerry Siegel (created by: Superman)", folderName, StringComparison.Ordinal); + } + + [Fact] + public void GetItemByNameFolderName_OverlongNamesSharingAPrefix_StayApart() + { + SetupPassThroughFileSystem(); + + var prefix = new string('a', 200); + + Assert.NotEqual( + BaseItem.GetItemByNameFolderName(prefix + "Joe Shuster"), + BaseItem.GetItemByNameFolderName(prefix + "Bob Kane")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_IsStable() + { + SetupPassThroughFileSystem(); + + var name = new string('a', 300); + + Assert.Equal(BaseItem.GetItemByNameFolderName(name), BaseItem.GetItemByNameFolderName(name)); + } + + private static void SetupPassThroughFileSystem() + { + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(x => x.GetValidFilename(It.IsAny<string>())).Returns((string name) => name); + BaseItem.FileSystem = fileSystem.Object; + } + + [Theory] [InlineData("", "")] [InlineData("1", "0000000001")] [InlineData("t", "t")] @@ -29,6 +146,35 @@ public class BaseItemTests => Assert.Equal(expected, BaseItem.ModifySortChunks(input)); [Theory] + [InlineData("The Matrix", "matrix")] + [InlineData("Spider-Man", "spiderman")] + [InlineData("A Movie: Part 2", "movie: part 0000000002")] + public void GetSortName_AppliesConfiguredCleaning(string input, string expected) + => Assert.Equal(expected, BaseItem.GetSortName(input, true, new ServerConfiguration())); + + [Fact] + public void GetSortName_WithoutAlphaNumericSorting_ReturnsTrimmedInput() + => Assert.Equal("The Matrix", BaseItem.GetSortName(" The Matrix", false, new ServerConfiguration())); + + [Fact] + public void SortName_ForcedSortName_IsCleanedLikeAutoSortName() + { + var configManager = new Mock<IServerConfigurationManager>(); + configManager.Setup(x => x.Configuration).Returns(new ServerConfiguration()); + BaseItem.ConfigurationManager = configManager.Object; + + const string Raw = "The Spider-Man: Homecoming"; + + var auto = new Video { Name = Raw }; + var forced = new Video { Name = "zzz unrelated name", ForcedSortName = Raw }; + + // A forced sort name must be cleaned the same way as an auto-generated one so both sort together (#17388). + Assert.Equal(auto.SortName, forced.SortName); + // Sanity: cleaning actually ran (leading article and hyphen removed, colon kept, lowercased). + Assert.Equal("spiderman: homecoming", forced.SortName); + } + + [Theory] [InlineData("/Movies/Ted/Ted.mp4", "/Movies/Ted/Ted - Unrated Edition.mp4", "Ted", "Unrated Edition")] [InlineData("/Movies/Deadpool 2 (2018)/Deadpool 2 (2018).mkv", "/Movies/Deadpool 2 (2018)/Deadpool 2 (2018) - Super Duper Cut.mkv", "Deadpool 2 (2018)", "Super Duper Cut")] public void GetMediaSourceName_Valid(string primaryPath, string altPath, string name, string altName) @@ -95,6 +241,17 @@ public class BaseItemTests "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]", "[Final Cut] [1080p HEVC AAC]", "[EE by ADM] [480p HEVC AAC]")] + // Numeric version labels: the dot between the digits is a decimal point, not a delimiter, so the + // prefix retreats past it to the '-' instead of leaving "0" / "11". + [InlineData( + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11", + "1.0", + "1.11")] + // Numeric labels with no structural delimiter at all fall back to the space boundary. + [InlineData("Movie (2007) 1.0", "Movie (2007) 1.11", "1.0", "1.11")] + // A dot followed by a non-digit is still a delimiter, even after a digit. + [InlineData("Movie - Part 1.HDR", "Movie - Part 1.SDR", "HDR", "SDR")] public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt) { var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv"; @@ -126,6 +283,24 @@ public class BaseItemTests } [Fact] + public void GetCommonVersionPrefix_NumericLabels_KeepsWholeNumber() + { + // Three versions labelled "1.0", "1.01" and "1.11": the common prefix stops inside the version + // number, so it must retreat past the decimal point to the '-' delimiter. + string[] fileNames = + [ + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.01", + "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11" + ]; + + var prefix = BaseItem.GetCommonVersionPrefix(fileNames); + + Assert.Equal("Evangelion 1.0 You Are (Not) Alone (2007) -", prefix); + Assert.Equal(["1.0", "1.01", "1.11"], fileNames.Select(n => n[prefix.Length..].TrimStart(' '))); + } + + [Fact] public void GetAlternateVersion_ReturnsMatchingLocalVersion() { var (primary, alt1, alt2) = SetupVersionGroup(); @@ -261,6 +436,85 @@ public class BaseItemTests Times.Never); } + [Theory] + // A version file the scan just found beside the episode is not linked yet, so it does not count + // towards MediaSourceCount. The episode still has to refresh its owned items, as that is what + // creates the item for the version and links it. + [InlineData(true, false, true)] + [InlineData(false, true, true)] + [InlineData(false, false, false)] + public void SupportsOwnedItems_EpisodeWithResolvedVersionOrPart_IsTrue(bool hasLocalVersion, bool isStacked, bool expected) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>()); + BaseItem.LibraryManager = libraryManager.Object; + + var episode = new Episode + { + Id = Guid.NewGuid(), + Path = "/TV/Show/Season 1/S01E01 - 1080p.mkv", + LocalAlternateVersions = hasLocalVersion ? ["/TV/Show/Season 1/S01E01 - 720p.mkv"] : [], + AdditionalParts = isStacked ? ["/TV/Show/Season 1/S01E01 - 1080p-part2.mkv"] : [] + }; + + var property = typeof(Episode).GetProperty("SupportsOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(property); + + Assert.Equal(expected, (bool)property!.GetValue(episode)!); + } + + [Theory] + // The season folder is the season's own, so the extras that sit in it are the season's. Whether + // the season holds one episode or two must not decide where its extras show up. + [InlineData(false, false)] + // An episode with a folder of its own keeps the extras in it, as nothing else searches there + [InlineData(true, true)] + public async Task RefreshedOwnedItems_EpisodeInAContainersOwnFolder_LeavesExtrasToTheContainer(bool episodeHasOwnFolder, bool expectSearch) + { + var seasonPath = Path.Combine("TV", "Show", "Season 1"); + var episodeFolder = episodeHasOwnFolder ? Path.Combine(seasonPath, "S01E01") : seasonPath; + var episodePath = Path.Combine(episodeFolder, "S01E01 - 1080p.mkv"); + + // The season needs a parent of its own, as an item without one maintains no owned items + var season = new Season { Id = Guid.NewGuid(), ParentId = Guid.NewGuid(), Path = seasonPath }; + var episode = new Episode + { + Id = Guid.NewGuid(), + ParentId = season.Id, + Path = episodePath, + // A version file is what makes an episode maintain owned items at all + LocalAlternateVersions = [Path.Combine(episodeFolder, "S01E01 - 720p.mkv")] + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(x => x.FileExists(It.IsAny<string>())).Returns(true); + BaseItem.FileSystem = fileSystem.Object; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetItemById(season.Id)).Returns(season); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())).Returns(Array.Empty<BaseItem>()); + libraryManager.Setup(x => x.FindExtras(It.IsAny<BaseItem>(), It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>())) + .Returns(Array.Empty<BaseItem>()); + BaseItem.LibraryManager = libraryManager.Object; + + var method = typeof(BaseItem).GetMethod("RefreshedOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var options = new MetadataRefreshOptions(Mock.Of<IDirectoryService>()); + await (Task<bool>)method!.Invoke(episode, [options, Array.Empty<FileSystemMetadata>(), CancellationToken.None])!; + + libraryManager.Verify( + x => x.FindExtras(episode, It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>()), + expectSearch ? Times.Once() : Times.Never()); + } + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() { var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; @@ -335,4 +589,165 @@ public class BaseItemTests Assert.Contains(alt2.Id, ids); } } + + [Fact] + public void InheritDatesFromOwner_OwnerHasDates_OverwritesOwnedItemDates() + { + var owner = new Movie + { + ProductionYear = 1982, + PremiereDate = new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc) + }; + + // 2016 is what the container creation date of a re-encoded trailer would have yielded. + var trailer = new Trailer + { + ExtraType = ExtraType.Trailer, + ProductionYear = 2016, + PremiereDate = new DateTime(2016, 5, 4, 0, 0, 0, DateTimeKind.Utc) + }; + + Assert.True(BaseItem.InheritDatesFromOwner(owner, trailer)); + Assert.Equal(owner.ProductionYear, trailer.ProductionYear); + Assert.Equal(owner.PremiereDate, trailer.PremiereDate); + } + + [Fact] + public void InheritDatesFromOwner_OwnerHasNoDates_KeepsOwnedItemDates() + { + var owner = new Movie(); + var trailer = new Trailer + { + ExtraType = ExtraType.Trailer, + ProductionYear = 1982, + PremiereDate = new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc) + }; + + Assert.False(BaseItem.InheritDatesFromOwner(owner, trailer)); + Assert.Equal(1982, trailer.ProductionYear); + Assert.Equal(new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc), trailer.PremiereDate); + } + + [Fact] + public void InheritDatesFromOwner_DatesAlreadyMatch_ReportsNoChange() + { + var owner = new Movie + { + ProductionYear = 1982, + PremiereDate = new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc) + }; + + var trailer = new Trailer + { + ExtraType = ExtraType.Trailer, + ProductionYear = owner.ProductionYear, + PremiereDate = owner.PremiereDate + }; + + Assert.False(BaseItem.InheritDatesFromOwner(owner, trailer)); + } + + [Fact] + public void InheritDatesFromOwner_OwnedItemHasNoDates_TakesOwnerDates() + { + var owner = new Movie + { + ProductionYear = 1982, + PremiereDate = new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc) + }; + + var trailer = new Trailer + { + ExtraType = ExtraType.Trailer + }; + + Assert.True(BaseItem.InheritDatesFromOwner(owner, trailer)); + Assert.Equal(1982, trailer.ProductionYear); + Assert.Equal(new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc), trailer.PremiereDate); + } + + [Theory] + // An extra named after a version belongs to that version, not to the primary whose name it + // also starts with + [InlineData("/Movies/Movie/Movie - 4K-trailer.mkv", 2)] + [InlineData("/Movies/Movie/Movie - 1080p-behindthescenes.mkv", 1)] + // Named after the movie rather than one of its versions + [InlineData("/Movies/Movie/Movie-trailer.mkv", 0)] + // In an extras folder, so named after nothing in particular + [InlineData("/Movies/Movie/trailers/Official.mkv", 0)] + // A version name is only a match when it is followed by the extra's own suffix + [InlineData("/Movies/Movie/Movie - 4Kish-trailer.mkv", 0)] + public void GetOwnerIdForExtra_AssignsExtraToItsVersion(string extraPath, int expectedVersion) + { + var (primary, alt1, alt2) = SetupVersionGroup(); + var expectedId = expectedVersion switch + { + 1 => alt1.Id, + 2 => alt2.Id, + _ => primary.Id + }; + + var method = typeof(Video).GetMethod("GetOwnerIdForExtra", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var ownerId = (Guid)method!.Invoke(primary, [new Video { Id = Guid.NewGuid(), Path = extraPath }])!; + + Assert.Equal(expectedId, ownerId); + } + + [Fact] + public void GetExtraOwnerIds_FromAnyVersion_CoversEveryVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetExtraOwnerIds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // An extra is owned by the one version it is named after, and the extras of the movie as a + // whole are owned by the primary, so every version has to read all of them back + foreach (var version in new[] { primary, alt1, alt2 }) + { + var ids = (Guid[])method!.Invoke(version, null)!; + + Assert.Equal(3, ids.Length); + Assert.Contains(primary.Id, ids); + Assert.Contains(alt1.Id, ids); + Assert.Contains(alt2.Id, ids); + } + } + + [Fact] + public void GetOwnedVersionIds_CoversEveryLocalVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetOwnedVersionIds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // The extras of all versions are maintained together, so all of them have to be read back + var ids = (Guid[])method!.Invoke(primary, null)!; + + Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids); + } + + private sealed class FailingEnumerationFolder(bool failAfterFirstChild, bool accessDenied) : Folder + { + public bool EnumerationAttempted { get; private set; } + + protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService) + { + EnumerationAttempted = true; + if (failAfterFirstChild) + { + yield return new Movie { Id = Guid.NewGuid(), Path = "/media/review-folder/movie.mkv" }; + } + + if (accessDenied) + { + throw new System.Security.SecurityException("Simulated access failure"); + } + + throw new IOException("Simulated directory read failure"); + } + } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs new file mode 100644 index 0000000000..705238317a --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +/// <summary> +/// Covers <see cref="Folder.ReleaseCachedChildren"/>, which a recursive scan calls as it unwinds so +/// the folders it walked do not keep the whole item graph of the library alive behind it. +/// </summary> +public class FolderChildCacheTests +{ + [Fact] + public void ReleaseCachedChildren_MakesTheNextAccessReload() + { + var folder = new TrackingFolder(); + + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + // Second access is served from the cache on the instance. + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + folder.ReleaseCachedChildren(); + + Assert.Empty(folder.Children); + Assert.Equal(2, folder.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_ReachesEveryLevelBelow() + { + var leaf = new TrackingFolder(); + var middle = new TrackingFolder { Source = [leaf] }; + var root = new TrackingFolder { Source = [middle] }; + + // Walk the whole tree, as a recursive scan does, so every level holds its children. + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(1, root.LoadCount); + Assert.Equal(1, middle.LoadCount); + Assert.Equal(1, leaf.LoadCount); + + root.ReleaseCachedChildren(); + + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(2, root.LoadCount); + Assert.Equal(2, middle.LoadCount); + Assert.Equal(2, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_LoadsNothingThatIsNotAlreadyHeld() + { + var leaf = new TrackingFolder(); + var root = new TrackingFolder { Source = [leaf] }; + + root.ReleaseCachedChildren(); + + Assert.Equal(0, root.LoadCount); + Assert.Equal(0, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_TerminatesOnACycle() + { + var first = new TrackingFolder(); + var second = new TrackingFolder { Source = [first] }; + first.Source = [second]; + + Assert.Single(first.Children); + Assert.Single(second.Children); + + // Clearing before descending is what stops this from recursing forever. + first.ReleaseCachedChildren(); + + Assert.Equal(1, first.LoadCount); + Assert.Equal(1, second.LoadCount); + } + + private sealed class TrackingFolder : Folder + { + public int LoadCount { get; private set; } + + public IReadOnlyList<BaseItem> Source { get; set; } = []; + + protected override IReadOnlyList<BaseItem> LoadChildren() + { + LoadCount++; + return Source; + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs new file mode 100644 index 0000000000..70da5eafe5 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Linq; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Model.Querying; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +public class PlaylistTests +{ + [Fact] + public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden() + { + // The SQL parental filter hides a container whose every member is blocked, so a listing + // built in memory has to reach the same answer. + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + SetupLibrary(blocked); + + Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" }; + SetupLibrary(blocked, allowed); + + Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser())); + } + + [Fact] + public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved() + { + var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; + var libraryManager = SetupLibrary(blocked); + var user = new User("user", "auth-provider", "reset-provider"); + + Assert.True(BuildPlaylist(blocked).IsVisible(user)); + + // Resolving a playlist's items is a query per playlist; nothing may run it for a user no + // rating keeps anything from. + libraryManager.Verify(x => x.GetItemList(It.IsAny<InternalItemsQuery>()), Times.Never); + } + + private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items) + { + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(items); + BaseItem.LibraryManager = libraryManager.Object; + + return libraryManager; + } + + private static Playlist BuildPlaylist(params BaseItem[] items) + { + // An empty path keeps the playlist out of the shared-playlist branch. + return new Playlist + { + Id = Guid.NewGuid(), + Name = "Playlist", + LinkedChildren = items.Select(LinkedChild.Create).ToArray() + }; + } + + private static User BuildRestrictedUser() + { + var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 }; + user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie }); + + return user; + } +} diff --git a/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs new file mode 100644 index 0000000000..b4ec2f1903 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using MediaBrowser.Controller.IO; +using Xunit; + +namespace Jellyfin.Controller.Tests.IO; + +public class FileSystemHelperTests +{ + private static readonly string _parentPath = Path.Combine(Path.GetTempPath(), "jellyfin-test", "root", "default"); + + [Theory] + [InlineData("Movies")] + [InlineData("My Movies")] + [InlineData("..2")] + [InlineData("a.b")] + public void GetChildPath_ValidName_ReturnsPathInsideParent(string name) + { + var path = FileSystemHelper.GetChildPath(_parentPath, name); + + Assert.Equal(Path.Combine(_parentPath, name), path); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("..")] + [InlineData("../..")] + [InlineData("../../etc")] + [InlineData("Movies/../..")] + [InlineData("/var/lib/jellyfin/data")] + [InlineData("sub/folder")] + [InlineData("with\0null")] + public void GetChildPath_EscapingName_ReturnsNull(string name) + { + Assert.Null(FileSystemHelper.GetChildPath(_parentPath, name)); + } + + [Theory] + [InlineData("..\\..")] + [InlineData("sub\\folder")] + [InlineData("C:\\Windows")] + public void GetChildPath_WindowsSeparator_DoesNotEscapeParent(string name) + { + var path = FileSystemHelper.GetChildPath(_parentPath, name); + + // On Windows these are rejected outright, on other platforms a backslash is a legal file name character. + Assert.True(path is null || string.Equals(Path.GetDirectoryName(path), _parentPath, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("...")] + [InlineData("Movies.")] + [InlineData("Movies ")] + public void GetChildPath_TrailingDotOrSpace_RejectedOnWindows(string name) + { + var path = FileSystemHelper.GetChildPath(_parentPath, name); + + if (OperatingSystem.IsWindows()) + { + // Windows trims trailing dots and spaces, so the name would resolve to the parent or to a different child. + Assert.Null(path); + } + else + { + Assert.Equal(Path.Combine(_parentPath, name), path); + } + } + + [Fact] + public void GetChildPath_ParentWithTrailingSeparator_ReturnsPathInsideParent() + { + var path = FileSystemHelper.GetChildPath(_parentPath + Path.DirectorySeparatorChar, "Movies"); + + Assert.Equal(Path.Combine(_parentPath, "Movies"), path); + } +} diff --git a/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs new file mode 100644 index 0000000000..686d839f4f --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.LibraryTaskScheduler; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests.LibraryTaskScheduler +{ + public class LimitedConcurrencyLibrarySchedulerTests + { + private static readonly TimeSpan _shortGracePeriod = TimeSpan.FromMilliseconds(50); + + // Generous, because these only ever wait for something that should already have happened. + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task Enqueue_ProcessesEveryItem() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await using (scheduler) + { + var data = Enumerable.Range(0, 100).ToArray(); + var processed = new ConcurrentBag<int>(); + + await scheduler.Enqueue( + data, + (item, _) => + { + processed.Add(item); + return Task.CompletedTask; + }, + new Progress<double>(), + CancellationToken.None); + + Assert.Equal(data, processed.Order()); + } + } + + [Fact] + public async Task Enqueue_WithFailingWorker_StillCompletes() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await using (scheduler) + { + await scheduler.Enqueue( + Enumerable.Range(0, 20).ToArray(), + (item, _) => item % 2 == 0 ? throw new InvalidOperationException("boom") : Task.CompletedTask, + new Progress<double>(), + CancellationToken.None); + } + } + + /// <summary> + /// The runners wait on a source linked to <see cref="IHostApplicationLifetime.ApplicationStopping"/>, + /// so a shutdown has to reach them. It does not travel from the linked source back to the one + /// the cleanup cancels, which is what made them immortal. + /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> + [Fact] + public async Task ApplicationStopping_RetiresRunners() + { + using var appStopping = new CancellationTokenSource(); + + // Long enough that the cleanup cannot be what retires them. + var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5)); + await using (scheduler) + { + await RunOneOperation(scheduler); + Assert.True(scheduler.ActiveRunnerCount > 0); + + await appStopping.CancelAsync(); + + await WaitForAsync(() => scheduler.ActiveRunnerCount == 0); + } + } + + /// <summary> + /// The cleanup used to be a one shot: it never released the scheduling slot it took, so + /// every runner spawned after the first pass stayed around for the lifetime of the server. + /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> + [Fact] + public async Task Enqueue_RetiresIdleRunnersAfterEveryOperation() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await using (scheduler) + { + for (var round = 0; round < 3; round++) + { + await RunOneOperation(scheduler); + Assert.True(scheduler.ActiveRunnerCount > 0, $"no runner spawned in round {round}"); + + await WaitForAsync(() => scheduler.ActiveRunnerCount == 0); + } + } + } + + /// <summary> + /// Disposing used to sit out the rest of the cleanup grace period, holding up shutdown for + /// up to a minute. + /// </summary> + /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns> + [Fact] + public async Task DisposeAsync_DoesNotWaitOutTheGracePeriod() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5)); + + await RunOneOperation(scheduler); + + var stopwatch = Stopwatch.StartNew(); + await scheduler.DisposeAsync(); + + Assert.True(stopwatch.Elapsed < _timeout, $"disposing took {stopwatch.Elapsed}"); + } + + [Fact] + public async Task Enqueue_AfterDispose_DoesNothing() + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping); + await scheduler.DisposeAsync(); + + var processed = 0; + await scheduler.Enqueue( + Enumerable.Range(0, 10).ToArray(), + (_, _) => + { + Interlocked.Increment(ref processed); + return Task.CompletedTask; + }, + new Progress<double>(), + CancellationToken.None); + + Assert.Equal(0, processed); + } + + [Theory] + [InlineData(1)] + [InlineData(4)] + public async Task Enqueue_FromWithinAWorker_DoesNotDeadlock(int fanout) + { + using var appStopping = new CancellationTokenSource(); + var scheduler = CreateScheduler(appStopping, fanout: fanout); + await using (scheduler) + { + var inner = 0; + + var outer = scheduler.Enqueue( + Enumerable.Range(0, 8).ToArray(), + (_, _) => scheduler.Enqueue( + Enumerable.Range(0, 4).ToArray(), + (_, _) => + { + Interlocked.Increment(ref inner); + return Task.CompletedTask; + }, + new Progress<double>(), + CancellationToken.None), + new Progress<double>(), + CancellationToken.None); + + await outer.WaitAsync(_timeout, TestContext.Current.CancellationToken); + + Assert.Equal(32, inner); + } + } + + private static LimitedConcurrencyLibraryScheduler CreateScheduler( + CancellationTokenSource appStopping, + int fanout = 4, + TimeSpan? gracePeriod = null) + { + var lifetime = new Mock<IHostApplicationLifetime>(); + lifetime.SetupGet(x => x.ApplicationStopping).Returns(() => appStopping.Token); + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.SetupGet(x => x.Configuration) + .Returns(new ServerConfiguration { LibraryScanFanoutConcurrency = fanout }); + + return new LimitedConcurrencyLibraryScheduler( + lifetime.Object, + NullLogger<LimitedConcurrencyLibraryScheduler>.Instance, + configurationManager.Object, + gracePeriod ?? _shortGracePeriod); + } + + private static Task RunOneOperation(LimitedConcurrencyLibraryScheduler scheduler) + => scheduler.Enqueue( + Enumerable.Range(0, 8).ToArray(), + (_, _) => Task.CompletedTask, + new Progress<double>(), + CancellationToken.None); + + private static async Task WaitForAsync(Func<bool> condition) + { + var stopwatch = Stopwatch.StartNew(); + while (!condition()) + { + Assert.True(stopwatch.Elapsed < _timeout, "timed out waiting for the scheduler to settle"); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs new file mode 100644 index 0000000000..557035e2d1 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs @@ -0,0 +1,162 @@ +using System; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using Moq; +using Xunit; + +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class EncodingHelperDoviTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("bt709", false)] + [InlineData("unknown", false)] + [InlineData("bt2020-10", false)] + [InlineData("smpte2084", true)] + [InlineData("arib-std-b67", true)] + public void GetSwVidFilterChain_InvalidDovi_OnlyTonemapsHdrBaseLayer(string? transfer, bool tonemap) + { + var state = CreateState("hevc", transfer); + var helper = CreateHelper(true); + + var (filters, _, _) = helper.GetSwVidFilterChain(state, new EncodingOptions(), "libx264"); + var args = string.Join(',', filters); + + Assert.Equal(VideoRangeType.DOVIInvalid, state.VideoStream.VideoRangeType); + Assert.Equal(tonemap, args.Contains("tonemapx=", StringComparison.Ordinal)); + Assert.Contains(tonemap ? "color_trc=" + transfer : "color_trc=bt709", args, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null, false)] + [InlineData("bt709", false)] + [InlineData("arib-std-b67", false)] + [InlineData("smpte2084", true)] + [InlineData("SMPTE2084", true)] + public void IsDoviWithHdr10Bl_InvalidDovi_RequiresPq(string? transfer, bool expected) + { + var stream = CreateState("hevc", transfer).VideoStream; + + Assert.True(EncodingHelper.IsDovi(stream)); + Assert.Equal(expected, EncodingHelper.IsDoviWithHdr10Bl(stream)); + } + + [Theory] + [InlineData("hevc", null, "hevc_metadata=remove_dovi=1")] + [InlineData("hevc", "bt709", "hevc_metadata=remove_dovi=1")] + [InlineData("hevc", "smpte2084", "hevc_metadata=remove_dovi=1")] + [InlineData("hevc", "arib-std-b67", "hevc_metadata=remove_dovi=1")] + [InlineData("av1", null, "av1_metadata=remove_dovi=1")] + [InlineData("av1", "bt709", "av1_metadata=remove_dovi=1")] + [InlineData("av1", "smpte2084", "av1_metadata=remove_dovi=1")] + [InlineData("av1", "arib-std-b67", "av1_metadata=remove_dovi=1")] + public void GetBitStreamArgs_InvalidDovi_PreservesClientDependentRemoval(string codec, string? transfer, string expected) + { + var state = CreateState(codec, transfer); + var helper = CreateHelper(true); + + foreach (var (requestedRanges, removeDovi) in new[] { (null, false), ("SDR", false), ("HDR10", false), ("DOVIWithEL", false), ("DOVI", true), ("SDR,DOVI", true) }) + { + state.BaseRequest.VideoRangeType = requestedRanges; + + Assert.Equal(removeDovi, helper.IsDoviRemoved(state)); + if (removeDovi) + { + Assert.Contains(expected, helper.GetBitStreamArgs(state, MediaStreamType.Video), StringComparison.Ordinal); + } + else + { + Assert.Equal(codec == "hevc" ? "-bsf:v hevc_mp4toannexb" : null, helper.GetBitStreamArgs(state, MediaStreamType.Video)); + } + + Assert.False(CreateHelper(false).IsDoviRemoved(state)); + } + } + + [Theory] + [InlineData(null, true)] + [InlineData("HDR10", true)] + [InlineData("DOVI", false)] + [InlineData("SDR,DOVI", false)] + public void CanStreamCopyVideo_InvalidDovi_RequiresRemovalSupportOnlyForDoviClients(string? requestedRanges, bool copyWithoutRemovalSupport) + { + foreach (var codec in new[] { "hevc", "av1" }) + { + foreach (var transfer in new[] { "bt709", "smpte2084" }) + { + var state = CreateState(codec, transfer); + state.BaseRequest.VideoRangeType = requestedRanges; + + Assert.True(CreateHelper(true).CanStreamCopyVideo(state, state.VideoStream)); + Assert.Equal(copyWithoutRemovalSupport, CreateHelper(false).CanStreamCopyVideo(state, state.VideoStream)); + } + } + } + + [Fact] + public void GetBitStreamArgs_ValidDovi_PreservesMetadata() + { + var state = CreateState("hevc", "smpte2084"); + state.VideoStream.ColorSpace = "bt2020nc"; + state.VideoStream.ColorPrimaries = "bt2020"; + state.BaseRequest.VideoRangeType = "DOVIWithEL"; + var helper = CreateHelper(true); + + Assert.False(helper.IsDoviRemoved(state)); + Assert.Equal("-bsf:v hevc_mp4toannexb", helper.GetBitStreamArgs(state, MediaStreamType.Video)); + } + + private static EncodingJobInfo CreateState(string codec, string? transfer) + { + var stream = new MediaStream + { + Type = MediaStreamType.Video, + Codec = codec, + Width = 1920, + Height = 1080, + BitDepth = 10, + DvProfile = codec == "hevc" ? 7 : 10, + DvBlSignalCompatibilityId = codec == "hevc" ? 6 : 1, + RpuPresentFlag = 1, + BlPresentFlag = 1, + ColorSpace = "bt709", + ColorPrimaries = "bt709", + ColorTransfer = transfer + }; + + return new EncodingJobInfo(TranscodingJobType.Hls) + { + VideoStream = stream, + MediaSource = new MediaSourceInfo { Container = "mkv", MediaStreams = [stream] }, + BaseRequest = new VideoRequestDto(), + OutputVideoCodec = "copy", + IsVideoRequest = true, + IsInputVideo = true + }; + } + + private static EncodingHelper CreateHelper(bool supportsRemoval) + { + var encoder = new Mock<IMediaEncoder>(); + encoder.Setup(x => x.SupportsBitStreamFilterWithOption(It.IsAny<BitStreamFilterOptionType>())).Returns(supportsRemoval); + encoder.Setup(x => x.SupportsFilter("tonemapx")).Returns(true); + encoder.SetupGet(x => x.EncoderVersion).Returns(new Version(8, 1)); + + return new EncodingHelper( + Mock.Of<IApplicationPaths>(), + encoder.Object, + Mock.Of<ISubtitleEncoder>(), + Mock.Of<IConfiguration>(), + Mock.Of<IConfigurationManager>(), + Mock.Of<IPathManager>()); + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs new file mode 100644 index 0000000000..586db2dd50 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs @@ -0,0 +1,40 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using Moq; +using Xunit; +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class EncodingHelperInferAudioCodecTests +{ + [Theory] + // Manifests and other containers that carry no inferable audio codec. + [InlineData("m3u8", "aac")] + [InlineData("mpd", "aac")] + [InlineData("wtv", "aac")] + [InlineData("", "aac")] + // Containers with a well known audio codec. + [InlineData("mp4", "aac")] + [InlineData("mkv", "aac")] + [InlineData("webm", "opus")] + [InlineData("ts", "mp3")] + // Containers named after the codec they carry. + [InlineData("flac", "flac")] + [InlineData("opus", "opus")] + [InlineData("ac3", "ac3")] + public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected) + { + Assert.Equal(expected, Create().InferAudioCodec(container)); + } + + private static EncodingHelper Create() + => new( + Mock.Of<IApplicationPaths>(), + Mock.Of<IMediaEncoder>(), + Mock.Of<ISubtitleEncoder>(), + Mock.Of<IConfiguration>(), + Mock.Of<IConfigurationManager>(), + Mock.Of<IPathManager>()); +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs index 71b6551d0f..2b009b4673 100644 --- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -223,12 +223,51 @@ public class EncodingHelperTests Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal); } - private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate) + [Theory] + [InlineData("wav")] + [InlineData("flac")] + [InlineData("mp3")] + public void GetProgressiveAudioFullCommandLine_PcmInRealContainer_KeepsContainerMuxer(string outputContainer) + { + // A pcm_* encoder must not drag the raw muxer into a container that writes its own header, + // or the client gets headerless PCM behind the container's content type. + var state = BuildAudioState("pcm_s16le", 48000, outputContainer); + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.DoesNotContain("-f s16le", args, StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveAudioFullCommandLine_PcmInPcmContainer_ForcesRawMuxer() + { + // The raw-PCM route added in #10321 for I2S/MCU clients must keep working. + var state = BuildAudioState("pcm_s16le", 48000, "pcm"); + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.Contains("-f s16le", args, StringComparison.Ordinal); + } + + [Fact] + public void GetProgressiveAudioFullCommandLine_PcmWithoutBitrate_EmitsNoEmptySampleRate() + { + // AudioBitRate is optional; it used to be emitted as `-ar <null>`, producing a bare `-ar` + // that made ffmpeg abort with "Expected number for ar" and the request fail with HTTP 500. + var state = BuildAudioState("pcm_s16le", 48000, "wav"); + state.BaseRequest.AudioBitRate = null; + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.DoesNotContain("-ar -", args, StringComparison.Ordinal); + Assert.DoesNotContain("-ar ", args, StringComparison.Ordinal); + Assert.Contains("-ar 48000", args, StringComparison.Ordinal); + } + + private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate, string? outputContainer = null) { var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 }; return new EncodingJobInfo(TranscodingJobType.Progressive) { + OutputContainer = outputContainer, MediaSource = new MediaSourceInfo { Container = "flac", diff --git a/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj new file mode 100644 index 0000000000..b6dc5dfb92 --- /dev/null +++ b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> + <PropertyGroup> + <ProjectGuid>{E24A279C-9A37-419A-8F9C-853C11FBE753}</ProjectGuid> + <OutputType>Exe</OutputType> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit.v3" /> + <PackageReference Include="xunit.runner.visualstudio"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="coverlet.collector"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="../../src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj" /> + </ItemGroup> + +</Project> diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs new file mode 100644 index 0000000000..18518df1c2 --- /dev/null +++ b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs @@ -0,0 +1,100 @@ +using SkiaSharp; +using Xunit; + +namespace Jellyfin.Drawing.Skia.Tests; + +/// <summary> +/// Covers what <see cref="SkiaEncoder.ResizeImage"/> does either side of a resize: at matching +/// dimensions it must not touch the image at all, and sharpening belongs to downscales only. +/// </summary> +public class SkiaEncoderResizeTests +{ + private static SKBitmap CreateEdgeBitmap(int width, int height) + { + var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul)); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(new SKColor(40, 60, 80)); + using var paint = new SKPaint { Color = new SKColor(220, 210, 200) }; + canvas.DrawRect(SKRect.Create(0, 0, width / 2f, height), paint); + + return bitmap; + } + + private static SKImageInfo InfoFor(SKBitmap source, int width, int height) + => new SKImageInfo(width, height, source.ColorType, source.AlphaType, source.ColorSpace); + + /// <summary> + /// Draws without sharpening, which is what the resize is expected to reduce to when it is not + /// downscaling. + /// </summary> + private static SKBitmap DrawOnly(SKBitmap source, SKImageInfo targetInfo, SKSamplingOptions sampling) + { + var target = new SKBitmap(targetInfo); + using var canvas = new SKCanvas(target); + using var paint = new SKPaint(); + canvas.DrawBitmap( + source, + SKRect.Create(0, 0, source.Width, source.Height), + SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height), + sampling, + paint); + + return target; + } + + private static void AssertSamePixels(SKBitmap expected, SKBitmap actual) + { + Assert.Equal(expected.Width, actual.Width); + Assert.Equal(expected.Height, actual.Height); + + for (var y = 0; y < expected.Height; y++) + { + for (var x = 0; x < expected.Width; x++) + { + Assert.Equal(expected.GetPixel(x, y), actual.GetPixel(x, y)); + } + } + } + + [Fact] + public void ResizeImage_MatchingDimensions_ReturnsTheImageUntouched() + { + using var source = CreateEdgeBitmap(16, 16); + + using var result = SkiaEncoder.ResizeImage(source, InfoFor(source, 16, 16)); + using var resultBitmap = SKBitmap.FromImage(result); + + // Unsharpened, so the edge is still exactly where it was. + AssertSamePixels(source, resultBitmap); + } + + [Fact] + public void ResizeImage_Upscale_DoesNotSharpen() + { + using var source = CreateEdgeBitmap(8, 8); + var targetInfo = InfoFor(source, 24, 24); + + using var result = SkiaEncoder.ResizeImage(source, targetInfo); + using var resultBitmap = SKBitmap.FromImage(result); + using var expected = DrawOnly(source, targetInfo, SkiaEncoder.UpscaleSamplingOptions); + + AssertSamePixels(expected, resultBitmap); + } + + [Fact] + public void ResizeImage_Downscale_StillSharpens() + { + using var source = CreateEdgeBitmap(32, 32); + var targetInfo = InfoFor(source, 16, 16); + + using var result = SkiaEncoder.ResizeImage(source, targetInfo); + using var resultBitmap = SKBitmap.FromImage(result); + using var unsharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions); + using var sharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions); + SkiaEncoder.SharpenInPlace(sharpened); + + AssertSamePixels(sharpened, resultBitmap); + // Guards the test itself: the edge has to be something sharpening actually changes. + Assert.NotEqual(unsharpened.GetPixel(8, 8), sharpened.GetPixel(8, 8)); + } +} diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs new file mode 100644 index 0000000000..72e555cc72 --- /dev/null +++ b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs @@ -0,0 +1,73 @@ +using SkiaSharp; +using Xunit; + +namespace Jellyfin.Drawing.Skia.Tests; + +public class SkiaEncoderSharpenTests +{ + private static SKBitmap CreateBitmap(int width, int height, SKColor fill) + { + var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul)); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(fill); + return bitmap; + } + + [Fact] + public void SharpenInPlace_UniformImage_IsUnchanged() + { + // 1.4 * v - 4 * 0.1 * v = v for any uniform value. + using var bitmap = CreateBitmap(8, 8, new SKColor(100, 150, 200)); + + SkiaEncoder.SharpenInPlace(bitmap); + + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + Assert.Equal(new SKColor(100, 150, 200), bitmap.GetPixel(x, y)); + } + } + } + + [Fact] + public void SharpenInPlace_BrightPixelOnDarkBackground_SharpensEdge() + { + using var bitmap = CreateBitmap(5, 5, new SKColor(50, 50, 50)); + bitmap.SetPixel(2, 2, new SKColor(250, 250, 250, 255)); + + SkiaEncoder.SharpenInPlace(bitmap); + + // Center: 1.4 * 250 - 0.1 * 4 * 50 = 330 -> clamped to 255. + Assert.Equal(new SKColor(255, 255, 255), bitmap.GetPixel(2, 2)); + // Direct neighbor: 1.4 * 50 - 0.1 * (250 + 3 * 50) = 30. + Assert.Equal(new SKColor(30, 30, 30), bitmap.GetPixel(1, 2)); + // Far corner is only surrounded by background: unchanged. + Assert.Equal(new SKColor(50, 50, 50), bitmap.GetPixel(0, 0)); + } + + [Fact] + public void SharpenInPlace_EdgePixels_ClampOutOfBoundsTaps() + { + // A corner pixel reuses itself for the two out-of-bounds taps: + // 1.4 * v - 0.1 * (2 * v + right + down). + using var bitmap = CreateBitmap(3, 3, new SKColor(100, 100, 100)); + bitmap.SetPixel(0, 0, new SKColor(200, 200, 200, 255)); + + SkiaEncoder.SharpenInPlace(bitmap); + + // 1.4 * 200 - 0.1 * (200 + 200 + 100 + 100) = 220. + Assert.Equal(new SKColor(220, 220, 220), bitmap.GetPixel(0, 0)); + } + + [Fact] + public void SharpenInPlace_UnsupportedColorType_IsLeftUntouched() + { + using var bitmap = new SKBitmap(new SKImageInfo(4, 4, SKColorType.Gray8, SKAlphaType.Opaque)); + bitmap.Erase(new SKColor(80, 80, 80)); + + SkiaEncoder.SharpenInPlace(bitmap); + + Assert.Equal(80, bitmap.GetPixel(1, 1).Red); + } +} diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs new file mode 100644 index 0000000000..30b7983ece --- /dev/null +++ b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs @@ -0,0 +1,99 @@ +using System.IO; +using Xunit; + +namespace Jellyfin.Drawing.Skia.Tests; + +public static class SvgSecurityValidatorTests +{ + public static TheoryData<string> ExternalReferenceSvgs => new() + { + // SSRF via <image> (xlink:href and plain href) + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='http://169.254.169.254/latest/meta-data/' width='16' height='16'/></svg>", + "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><image href='https://example.invalid/a.png' width='16' height='16'/></svg>", + // Local file disclosure + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///etc/passwd' width='16' height='16'/></svg>", + // Memory exhaustion DoS + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///dev/urandom' width='16' height='16'/></svg>", + // <use> external reference + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><use xlink:href='http://example.invalid/c.svg#a'/></svg>", + // CSS url() external reference in an attribute + "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' style=\"fill:url(http://example.invalid/d.svg#g)\"/></svg>", + // @import in a style block + "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><style>@import 'http://example.invalid/e.css';</style><rect width='16' height='16'/></svg>", + // Relative path traversal (resolves against the document location -> local file read) + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='../../../../etc/hosts' width='16' height='16'/></svg>", + // XXE via external entity + "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&xxe;</text></svg>", + // Entity-expansion (billion laughs) denial of service + "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY a 'aaaaaaaaaa'><!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'><!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'><!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'><!ENTITY e '&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;'><!ENTITY f '&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&f;</text></svg>", + // Nested SVG in a base64 data: URI whose inner document references an external resource + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jz48aW1hZ2UgeGxpbms6aHJlZj0naHR0cDovL2V4YW1wbGUuaW52YWxpZC9uZXN0ZWQucG5nJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jy8+PC9zdmc+' width='16' height='16'/></svg>", + // Nested SVG in a URL-encoded (non-base64) data: URI referencing an external resource + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20xmlns%3Axlink%3D%27http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%27%3E%3Cimage%20xlink%3Ahref%3D%27file%3A%2F%2F%2Fetc%2Fpasswd%27%2F%3E%3C%2Fsvg%3E' width='16' height='16'/></svg>", + // Nested gzip-compressed (svgz) data: URI whose inner document references an external resource + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/23OwQrDIBAE0F/x5s217aWK8V+E2N2laiWRKP36Nin0lNvAPIZx64Zi5FTWSVJr1QL03lW/qdeCcNVaw1fIH7EjcXmewYsxBo5Wis5zo0nepaDISG2P3nEOGMVBLC3x8V+JI+SaouKyhcQz4FvVgucz4N1+x38AdK4P3LYAAAA=' width='16' height='16'/></svg>", + }; + + public static TheoryData<string> SafeSvgs => new() + { + "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='red'/></svg>", + // Same-document fragment references are allowed + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><defs><linearGradient id='g'/></defs><rect width='16' height='16' fill='url(#g)'/><use xlink:href='#g'/></svg>", + // Inline data URIs are allowed + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' width='16' height='16'/></svg>", + // A DOCTYPE without external entities is allowed + "<?xml version='1.0'?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>", + // An internal general entity with no external reference is allowed (and is expanded by the renderer) + "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY col 'red'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='&col;'/></svg>", + // A nested data:image/svg+xml payload that is itself self-contained is allowed + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc4JyBoZWlnaHQ9JzgnPjxyZWN0IHdpZHRoPSc4JyBoZWlnaHQ9JzgnIGZpbGw9J2JsdWUnLz48L3N2Zz4=' width='16' height='16'/></svg>", + // A self-contained gzip-compressed (svgz) data: URI is allowed + "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/22Muw6AIAwAf6VbN0p0MQb4GBWBBB+Bav18ZXe75C5n6h3g2fJeLUbmcyQSESW9OkqgTmtNX4EgaeFocUCIPoXIDZ0pfuZfBWvK2eKUL4/kTHu4F2NB6oFrAAAA' width='16' height='16'/></svg>", + }; + + [Theory] + [MemberData(nameof(ExternalReferenceSvgs))] + public static void IsSafe_ExternalReference_ReturnsFalse(string svg) + { + var path = WriteTemp(svg); + try + { + Assert.False(SvgSecurityValidator.IsSafe(path, out var reason)); + Assert.NotNull(reason); + } + finally + { + File.Delete(path); + } + } + + [Theory] + [MemberData(nameof(SafeSvgs))] + public static void IsSafe_NoExternalReference_ReturnsTrue(string svg) + { + var path = WriteTemp(svg); + try + { + Assert.True(SvgSecurityValidator.IsSafe(path, out var reason)); + Assert.Null(reason); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public static void IsSafe_MissingFile_ReturnsFalse() + { + Assert.False(SvgSecurityValidator.IsSafe(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Path.GetRandomFileName() + ".svg"), out var reason)); + Assert.NotNull(reason); + } + + private static string WriteTemp(string svg) + { + var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".svg"); + File.WriteAllText(path, svg); + return path; + } +} diff --git a/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs new file mode 100644 index 0000000000..71fd853ba2 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using Jellyfin.Extensions; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public static class PathHelperTests + { + [Theory] + [InlineData("file.txt", "file.txt")] + [InlineData("sub/file.txt", "file.txt")] + [InlineData("../../etc/passwd", "passwd")] + public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected) + { + Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(".")] + [InlineData("..")] + public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input) + { + Assert.Null(PathHelper.GetSafeLeafFileName(input)); + } + + [Fact] + public static void IsContainedIn_ChildPath_ReturnsTrue() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + var child = Path.Combine(root, "sub", "file.txt"); + Assert.True(PathHelper.IsContainedIn(root, child)); + } + + [Fact] + public static void IsContainedIn_RootItself_ReturnsTrue() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + Assert.True(PathHelper.IsContainedIn(root, root)); + } + + [Fact] + public static void IsContainedIn_TraversalEscape_ReturnsFalse() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + var escape = Path.Combine(root, "..", "..", "etc", "passwd"); + Assert.False(PathHelper.IsContainedIn(root, escape)); + } + + [Fact] + public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse() + { + // "/var/data" must not be accepted as a parent of "/var/dataset". + var root = Path.Combine(Path.GetTempPath(), "data"); + var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt"); + Assert.False(PathHelper.IsContainedIn(root, sibling)); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs index 028f12afa7..0851570396 100644 --- a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs @@ -75,5 +75,28 @@ namespace Jellyfin.Extensions.Tests var result = str.AsSpan().RightPart(needle).ToString(); Assert.Equal(expectedResult, result); } + + [Theory] + [InlineData("", "")] + [InlineData("/media/movies/Film.mkv", "/media/movies/Film.mkv")] + [InlineData(@"C:\media\movies\Film.mkv", @"C:\media\movies\Film.mkv")] + [InlineData(@"/media/a""b.mkv", @"/media/a\""b.mkv")] + [InlineData(@"/media/a\""b.mkv", @"/media/a\\\""b.mkv")] + [InlineData(@"/media/a\\""b.mkv", @"/media/a\\\\\""b.mkv")] + [InlineData(@"/media/a\b""c.mkv", @"/media/a\b\""c.mkv")] + [InlineData(@"/media/trailing\", @"/media/trailing\\")] + [InlineData(@"/media/evil\"" -f lavfi -i sine .mkv", @"/media/evil\\\"" -f lavfi -i sine .mkv")] + public void EscapeProcessArgument_ValidInput_Corrects(string input, string expectedResult) + { + Assert.Equal(expectedResult, input.EscapeProcessArgument()); + } + + [Theory] + [InlineData("/media/movies/Film with spaces.mkv")] + [InlineData(@"C:\media\movies\Film.mkv")] + public void EscapeProcessArgument_NothingToEscape_ReturnsSameInstance(string input) + { + Assert.Same(input, input.EscapeProcessArgument()); + } } } diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs new file mode 100644 index 0000000000..04c7b05c3d --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using Xunit; +using SchedulesDirectProvider = Jellyfin.LiveTv.Listings.SchedulesDirect; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class SchedulesDirectLineupTests +{ + private const string InvalidUserResponse = "{\"response\":\"INVALID_USER\",\"code\":4003,\"message\":\"Invalid user.\",\"serverID\":\"AWS-SD-web.1\"}"; + + private static readonly ListingsProviderInfo _info = new() { Username = "user", Password = "password" }; + + [Fact] + public async Task GetLineups_ValidCredentials_ReturnsLineups() + { + var tokenResponse = await CreateSuccessfulLogin(); + using var provider = CreateProvider(tokenResponse, await GetHeadendsResponse()); + + var lineups = await provider.GetLineups(_info, "USA", "90210"); + + Assert.NotEmpty(lineups); + Assert.Contains(lineups, i => string.Equals(i.Id, "USA-OTA-90210", StringComparison.Ordinal)); + Assert.Contains(lineups, i => string.Equals(i.Name, "Antenna", StringComparison.Ordinal)); + } + + [Fact] + public async Task GetLineups_LoginFails_Throws() + { + using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse()); + + // An empty lineup list is indistinguishable from "no lineups for this location", so a + // failed login has to surface as an error instead. + await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210")); + } + + [Fact] + public async Task Validate_LoginFails_Throws() + { + using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse()); + + await Assert.ThrowsAnyAsync<Exception>(() => provider.Validate(_info, true, false)); + } + + [Fact] + public async Task Validate_AfterAccountError_RecoversWithoutRestart() + { + var login = CreateFailedLogin(); + using var provider = CreateProvider(login, await GetHeadendsResponse()); + + await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210")); + + // The account error disables Schedules Direct; saving the provider again is the user + // correcting their credentials, and that has to recover without a server restart. + login.Status = HttpStatusCode.OK; + login.Body = await GetTokenResponse(); + + await provider.Validate(_info, true, false); + + Assert.NotEmpty(await provider.GetLineups(_info, "USA", "90210")); + } + + private static async Task<Response> CreateSuccessfulLogin() + => new() { Status = HttpStatusCode.OK, Body = await GetTokenResponse() }; + + private static Response CreateFailedLogin() + => new() { Status = HttpStatusCode.BadRequest, Body = InvalidUserResponse }; + + private static Task<string> GetTokenResponse() + => File.ReadAllTextAsync("Test Data/SchedulesDirect/token_live_response.json", TestContext.Current.CancellationToken); + + private static Task<string> GetHeadendsResponse() + => File.ReadAllTextAsync("Test Data/SchedulesDirect/headends_response.json", TestContext.Current.CancellationToken); + + private static SchedulesDirectProvider CreateProvider(Response login, string headendsResponse) + { + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>((m, _) => + { + var path = m.RequestUri!.AbsolutePath; + if (path.EndsWith("/token", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(login.Status) + { + Content = new StringContent(login.Body) + }); + } + + if (path.EndsWith("/headends", StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(headendsResponse) + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + }); + + var httpClientFactory = new Mock<IHttpClientFactory>(); + httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(() => new HttpClient(messageHandler.Object)); + + var appPaths = new Mock<IApplicationPaths>(); + appPaths.SetupGet(x => x.CachePath).Returns(Path.GetTempPath()); + + return new SchedulesDirectProvider( + NullLogger<SchedulesDirectProvider>.Instance, + httpClientFactory.Object, + appPaths.Object); + } + + private sealed class Response + { + public HttpStatusCode Status { get; set; } + + public string Body { get; set; } = string.Empty; + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs new file mode 100644 index 0000000000..1d96c5a958 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public sealed class XmlTvListingsProviderCacheTests : IDisposable +{ + private const string ChannelId = "3297"; + + private readonly string _cachePath = Path.Combine(Path.GetTempPath(), "jellyfin-xmltv-tests-" + Guid.NewGuid().ToString("N")); + + private readonly ListingsProviderInfo _info = new() + { + Id = "cachetests", + Path = "https://example.com/notitle.xml" + }; + + private bool _downloadsFail; + private Exception? _downloadException; + + public void Dispose() + { + if (Directory.Exists(_cachePath)) + { + Directory.Delete(_cachePath, true); + } + } + + [Fact] + public async Task GetProgramsAsync_DownloadFailsAfterASuccess_KeepsUsingTheCachedListings() + { + var provider = CreateProvider(); + + Assert.NotEmpty(await GetPrograms(provider)); + + // Age the cached copy out, so the next call goes back to the (now broken) source. + var cacheFile = Path.Combine(_cachePath, "xmltv", _info.Id + ".xml"); + File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddDays(-1)); + _downloadsFail = true; + + // Losing the listings entirely because a single download failed empties the whole guide. + Assert.NotEmpty(await GetPrograms(provider)); + Assert.True(File.Exists(cacheFile)); + } + + [Fact] + public async Task GetProgramsAsync_DownloadTimesOut_DoesNotSurfaceAsCancellation() + { + var provider = CreateProvider(); + + // This is how HttpClient reports its own timeout. Left as an OperationCanceledException it + // aborts the guide refresh for every channel and provider instead of only this one. + _downloadsFail = true; + _downloadException = new TaskCanceledException("timeout", new TimeoutException()); + + await Assert.ThrowsAsync<TimeoutException>(() => GetPrograms(provider)); + } + + [Fact] + public async Task GetProgramsAsync_ProviderSavedAfterAFailure_DownloadsAgain() + { + var provider = CreateProvider(); + + _downloadsFail = true; + await Assert.ThrowsAnyAsync<Exception>(() => GetPrograms(provider)); + + // Without clearing the backoff the guide stays empty for an hour, even though saving the + // provider deletes the cached file and is the user asking for another attempt. + _downloadsFail = false; + await provider.Validate(_info, true, true); + + Assert.NotEmpty(await GetPrograms(provider)); + } + + private async Task<ProgramInfo[]> GetPrograms(XmlTvListingsProvider provider) + { + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await provider.GetProgramsAsync(_info, ChannelId, startDate, startDate.AddDays(1), CancellationToken.None); + + return programs.ToArray(); + } + + private XmlTvListingsProvider CreateProvider() + { + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>((m, _) => + { + if (_downloadException is not null) + { + return Task.FromException<HttpResponseMessage>(_downloadException); + } + + if (_downloadsFail) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1]))) + }); + }); + + var httpClientFactory = new Mock<IHttpClientFactory>(); + httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(() => new HttpClient(messageHandler.Object)); + + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.SetupGet(x => x.CachePath).Returns(_cachePath); + + var config = new Mock<IServerConfigurationManager>(); + config.SetupGet(x => x.ApplicationPaths).Returns(appPaths.Object); + config.SetupGet(x => x.Configuration).Returns(new ServerConfiguration()); + + return new XmlTvListingsProvider( + config.Object, + httpClientFactory.Object, + NullLogger<XmlTvListingsProvider>.Instance); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs index b71dc15201..a8ebc8c9b9 100644 --- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; using Moq; using Moq.Protected; @@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests Assert.True(program.HasImage); Assert.Equal("https://domain.tld/image.png", program.ImageUrl); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); } [Theory] @@ -85,5 +87,103 @@ public class XmlTvListingsProviderTests var program = programsList[0]; Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")] + [InlineData("https://example.com/no-optional-elements.xml")] + public async Task GetProgramsAsync_NoOptionalElements_Success(string path) + { + var info = new ListingsProviderInfo() + { + Id = "no-optional-elements-programs", + Path = path + }; + + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var program = Assert.Single(programs.ToList()); + Assert.Equal("Programme Without Icon Or Rating", program.Name); + Assert.False(program.HasImage); + Assert.Null(program.ImageUrl); + Assert.Null(program.ThumbImageUrl); + Assert.Null(program.BackdropImageUrl); + Assert.Null(program.OfficialRating); + Assert.Null(program.CommunityRating); + AssertXmlTvEtag(program.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")] + [InlineData("https://example.com/no-optional-elements.xml")] + public async Task GetChannels_NoOptionalElements_Success(string path) + { + var info = new ListingsProviderInfo() + { + Id = "no-optional-elements-channels", + Path = path + }; + + var channels = await _xmlTvListingsProvider.GetChannels(info, CancellationToken.None); + var channel = Assert.Single(channels); + Assert.Equal("3297", channel.Id); + Assert.Equal("Channel Without Icon", channel.Name); + Assert.Equal("3297", channel.Number); + Assert.Null(channel.ImageUrl); + } + + [Fact] + public async Task GetProgramsAsync_Etag_SameContentIsStable() + { + var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + + Assert.Equal(first.Etag, second.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")] + public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var changed = await GetSingleProgramAsync(changedPath); + + Assert.NotEqual(original.Etag, changed.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")] + public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var equivalent = await GetSingleProgramAsync(equivalentPath); + + Assert.Equal(original.Etag, equivalent.Etag); + } + + private async Task<ProgramInfo> GetSingleProgramAsync(string path) + { + var info = new ListingsProviderInfo() + { + Id = Path.GetFileNameWithoutExtension(path), + Path = path + }; + + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + + return Assert.Single(programs.ToList()); + } + + private static void AssertXmlTvEtag(string? etag) + { + Assert.NotNull(etag); + Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal); } } diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs new file mode 100644 index 0000000000..b8d1c60e1a --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs @@ -0,0 +1,59 @@ +using System; +using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class XmlTvProgramEtagTests +{ + [Fact] + public void TryCreate_GenreOrderIsSignificant() + { + // GuideManager assigns item.Genres = info.Genres.ToArray() preserving order, + // so the same genres in a different order is a real mapped-content change. + var first = NewProgram(); + first.Genres = new() { "Drama", "Action" }; + + var second = NewProgram(); + second.Genres = new() { "Action", "Drama" }; + + Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _)); + Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _)); + Assert.NotEqual(firstEtag, secondEtag); + } + + [Fact] + public void MatchesStored_EqualXmlTvEtags_ReturnsTrue() + { + const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789"; + Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + [Fact] + public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse() + { + Assert.False(XmlTvProgramEtag.MatchesStored( + XmlTvProgramEtag.Prefix + "AAAA", + XmlTvProgramEtag.Prefix + "BBBB")); + } + + [Fact] + public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse() + { + // Other providers (e.g. Schedules Direct) use their own etag schemes. + // The IsXmlTvEtag gate must keep them on the field-by-field update path + // even when their incoming and stored values happen to match exactly. + const string Etag = "sd-abc123"; + Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + private static ProgramInfo NewProgram() => new() + { + Id = "program-id", + ChannelId = "channel-id", + Name = "Program Name", + StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc), + }; +} diff --git a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs deleted file mode 100644 index f44cb88834..0000000000 --- a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Jellyfin.LiveTv; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Entities; -using Xunit; - -namespace Jellyfin.LiveTv.Tests; - -public class LiveTvChannelImageHelperTests -{ - [Fact] - public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate() - { - var channel = new LiveTvChannel { Name = "Test Channel" }; - - var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null); - - Assert.False(updated); - Assert.False(channel.HasImage(ImageType.Primary)); - } - - [Fact] - public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl() - { - var channel = new LiveTvChannel { Name = "Test Channel" }; - - var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( - channel, - null, - "https://example.com/icon.png"); - - Assert.True(updated); - Assert.True(channel.HasImage(ImageType.Primary)); - Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); - } - - [Fact] - public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates() - { - var channel = new LiveTvChannel { Name = "Test Channel" }; - LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png"); - - var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( - channel, - null, - "https://example.com/icon.png"); - - Assert.True(updated); - Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); - } -} diff --git a/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs new file mode 100644 index 0000000000..4487a5ff2b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.TunerHosts; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public class M3UTunerHostTests + { + [Theory] + // A manifest is not a byte stream, so it must never be offered for direct play. + [InlineData("http://example.com/live/1234.m3u8", false)] + [InlineData("http://example.com/live/1234.m3u8?token=abc", false)] + [InlineData("http://example.com/live/1234.mpd", false)] + // Byte streams are unaffected. + [InlineData("http://example.com/live/1234.ts", true)] + [InlineData("http://example.com/live/1234", true)] + public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay) + { + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http); + + var host = new TestableM3UTunerHost( + Mock.Of<IServerConfigurationManager>(), + mediaSourceManager.Object, + Mock.Of<ILogger<M3UTunerHost>>(), + Mock.Of<IFileSystem>(), + Mock.Of<IHttpClientFactory>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<INetworkManager>(), + Mock.Of<IStreamHelper>()); + + var sources = await host.GetMediaSources( + new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false }, + new ChannelInfo { Path = path }); + + Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay); + } + + private sealed class TestableM3UTunerHost : M3UTunerHost + { + public TestableM3UTunerHost( + IServerConfigurationManager config, + IMediaSourceManager mediaSourceManager, + ILogger<M3UTunerHost> logger, + IFileSystem fileSystem, + IHttpClientFactory httpClientFactory, + IServerApplicationHost appHost, + INetworkManager networkManager, + IStreamHelper streamHelper) + : base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper) + { + } + + public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel) + => GetChannelStreamMediaSources(tuner, channel, CancellationToken.None); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs index 59cd42c05b..1bc42d5fe5 100644 --- a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs @@ -176,6 +176,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect } /// <summary> + /// /metadata/programs response where the daily image limit is hit mid-batch, + /// so individual entries carry an error code inside an otherwise successful response. + /// </summary> + [Fact] + public void Deserialize_Metadata_Programs_Image_Limit_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_image_limit_response.json"); + var showImagesDtos = JsonSerializer.Deserialize<IReadOnlyList<ShowImagesDto>>(bytes, _jsonOptions); + + Assert.NotNull(showImagesDtos); + Assert.Equal(2, showImagesDtos!.Count); + + // First entry is a normal result with image data and no error code. + Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); + Assert.Null(showImagesDtos[0].Code); + Assert.Single(showImagesDtos[0].Data); + + // Second entry is a per-entry trial image download limit error (SD code 5003). + Assert.Equal("SH00712241", showImagesDtos[1].ProgramId); + Assert.Equal((int)SdErrorCode.MaxImageDownloadsTrial, showImagesDtos[1].Code); + Assert.Empty(showImagesDtos[1].Data); + } + + /// <summary> /// /headends response. /// </summary> [Fact] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml new file mode 100644 index 0000000000..15f85f57e6 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml new file mode 100644 index 0000000000..2b49c3bccd --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">sports</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml new file mode 100644 index 0000000000..090273ac98 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Changed description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml new file mode 100644 index 0000000000..532b91da20 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/changed.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml new file mode 100644 index 0000000000..db0d5e86de --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789013</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml new file mode 100644 index 0000000000..168c0a643b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" stop="20221104140000 +0000" start="20221104130000 +0000"> + <icon src="https://domain.tld/base.png"/> + <star-rating> + <value>3/5</value> + </star-rating> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <category lang="en">series</category> + <desc lang="en">Base description.</desc> + <sub-title lang="en">Base Episode</sub-title> + <title lang="en">Base Program</title> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml new file mode 100644 index 0000000000..73288e7c57 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml @@ -0,0 +1,17 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Changed Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml new file mode 100644 index 0000000000..d0ff1b82f5 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml @@ -0,0 +1,18 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000"> + <title lang="en">Base Program</title> + <sub-title lang="en">Base Episode</sub-title> + <desc lang="en">Base description.</desc> + <category lang="en">series</category> + <episode-num system="xmltv_ns">0 . 1 . </episode-num> + <episode-num system="dd_progid">EP123456789012</episode-num> + <rating system="VCHIP"> + <value>TV-G</value> + </rating> + <star-rating> + <value>3/5</value> + </star-rating> + <previously-unknown-field>Ignored by Jellyfin XMLTV mapping.</previously-unknown-field> + <icon src="https://domain.tld/base.png"/> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml new file mode 100644 index 0000000000..e82d00c259 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml @@ -0,0 +1,10 @@ +<tv date="20221104"> + <channel id="3297"> + <display-name>Channel Without Icon</display-name> + </channel> + <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000"> + <title lang="en">Programme Without Icon Or Rating</title> + <desc lang="en">A programme that only uses the required XMLTV elements.</desc> + <category lang="en">sports</category> + </programme> +</tv> diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json new file mode 100644 index 0000000000..34931aa769 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json @@ -0,0 +1 @@ +[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]},{"programID":"SH00712241","code":5003,"message":"Image download limit exceeded. Try again tomorrow."}] diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs new file mode 100644 index 0000000000..141164815c --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.MediaEncoding.Encoder; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests.Encoder; + +public class ProcessWrapperTests +{ + [Fact] + public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt() + { + using var process = CreateProcess(); + using var exitHandled = new ManualResetEventSlim(false); + + using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder())) + { + // Subscribed after the wrapper, so by the time this is set the wrapper's own handler has + // already run: whatever it does to the process has happened. + process.Exited += (_, _) => exitHandled.Set(); + + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited."); + + // The caller still owns the process here. Disposing it from the exit handler handed + // whoever exited quickest an ObjectDisposedException out of these three lines. + var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + Assert.Equal("jellyfin", output.Trim()); + + Assert.True(wrapper.HasExited); + Assert.Equal(3, wrapper.ExitCode); + } + } + + [Fact] + public async Task ExitState_IsReadableBeforeTheExitEventArrives() + { + using var process = CreateProcess(); + + using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder())) + { + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + // The exit event is raised on the thread pool and can lag behind the wait that just + // returned, so neither of these may depend on it having arrived. + Assert.True(wrapper.HasExited); + Assert.Equal(3, wrapper.ExitCode); + } + } + + [Fact] + public async Task ExitCode_SurvivesDisposal() + { + using var process = CreateProcess(); + var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()); + + process.Start(); + await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + var exitCode = wrapper.ExitCode; + wrapper.Dispose(); + + Assert.Equal(exitCode, wrapper.ExitCode); + Assert.True(wrapper.HasExited); + } + + private static MediaEncoder CreateEncoder() + => new( + Mock.Of<ILogger<MediaEncoder>>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<IFileSystem>(), + Mock.Of<IBlurayExaminer>(), + Mock.Of<ILocalizationManager>(), + new ConfigurationBuilder().Build(), + Mock.Of<IServerConfigurationManager>()); + + // Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that + // rejects a file outright - the process that used to win the race against its own caller. + private static Process CreateProcess() + { + var startInfo = OperatingSystem.IsWindows() + ? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3") + : new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\""); + + startInfo.CreateNoWindow = true; + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + + return new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs index 988073074b..bfe6ade1fe 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs @@ -24,6 +24,7 @@ namespace Jellyfin.MediaEncoding.Tests [InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV432Output, false)] [InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, true)] + [InlineData(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, true)] [InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)] public void ValidateVersionInternalTest(string versionOutput, bool valid) { @@ -41,6 +42,7 @@ namespace Jellyfin.MediaEncoding.Tests Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4)); Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2)); Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 4)); + Add(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, new Version(4, 4)); Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null); } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs index 1f2d618aa4..604b862fbe 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs @@ -86,6 +86,15 @@ libswscale 5. 9.100 / 5. 9.100 libswresample 3. 9.100 / 3. 9.100 libpostproc 55. 9.100 / 55. 9.100"; + public const string FFmpegGitWithoutLibpostprocOutput = @"ffmpeg version N-122128-gdeadbeef Copyright (c) 2000-2026 the FFmpeg developers +libavutil 60. 26.102 / 60. 26.102 +libavcodec 62. 28.102 / 62. 28.102 +libavformat 62. 12.102 / 62. 12.102 +libavdevice 62. 3.102 / 62. 3.102 +libavfilter 11. 14.102 / 11. 14.102 +libswscale 9. 5.102 / 9. 5.102 +libswresample 6. 3.102 / 6. 3.102"; + public const string FFmpegGitUnknownOutput = @"ffmpeg version N-45325-gb173e0353-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2018 the FFmpeg developers built with gcc 6.3.0 (Debian 6.3.0-18+deb9u1) 20170516 configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc-6 --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gray --enable-libfribidi --enable-libass --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libzimg diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index b723fc7208..52e0b19700 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -219,7 +219,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("eng", res.MediaStreams[4].Language); Assert.Equal(MediaStreamType.Subtitle, res.MediaStreams[4].Type); Assert.Equal("mov_text", res.MediaStreams[4].Codec); - Assert.Null(res.MediaStreams[4].Title); + Assert.Equal("SDH", res.MediaStreams[4].Title); Assert.True(res.MediaStreams[4].IsHearingImpaired); Assert.Equal("eng", res.MediaStreams[5].Language); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs index 1f908d7e0e..b03651e5e9 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs @@ -15,13 +15,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example.ass"); var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass"); - Assert.Single(parsed.TrackEvents); - var trackEvent = parsed.TrackEvents[0]; + Assert.Single(parsed.Paragraphs); + var paragraph = parsed.Paragraphs[0]; - Assert.Equal("1", trackEvent.Id); - Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); - Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text); + Assert.Equal(1, paragraph.Number); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks); + Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", paragraph.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs index b7152961cd..01a35e6cb0 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs @@ -15,19 +15,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example.srt"); var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); - Assert.Equal(2, parsed.TrackEvents.Count); - - var trackEvent1 = parsed.TrackEvents[0]; - Assert.Equal("1", trackEvent1.Id); - Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); - Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text); - - var trackEvent2 = parsed.TrackEvents[1]; - Assert.Equal("2", trackEvent2.Id); - Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); - Assert.Equal("Very good, Lieutenant.", trackEvent2.Text); + Assert.Equal(2, parsed.Paragraphs.Count); + + var paragraph1 = parsed.Paragraphs[0]; + Assert.Equal(1, paragraph1.Number); + Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks); + Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", paragraph1.Text); + + var paragraph2 = parsed.Paragraphs[1]; + Assert.Equal(2, paragraph2.Number); + Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks); + Assert.Equal("Very good, Lieutenant.", paragraph2.Text); } [Fact] @@ -36,19 +36,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example2.srt"); var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); - Assert.Equal(2, parsed.TrackEvents.Count); - - var trackEvent1 = parsed.TrackEvents[0]; - Assert.Equal("311", trackEvent1.Id); - Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); - Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text); - - var trackEvent2 = parsed.TrackEvents[1]; - Assert.Equal("312", trackEvent2.Id); - Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); - Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text); + Assert.Equal(2, parsed.Paragraphs.Count); + + var paragraph1 = parsed.Paragraphs[0]; + Assert.Equal(311, paragraph1.Number); + Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks); + Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", paragraph1.Text); + + var paragraph2 = parsed.Paragraphs[1]; + Assert.Equal(312, paragraph2.Number); + Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks); + Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", paragraph2.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs index 5b7aa7eaa9..d814088593 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs @@ -20,19 +20,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests { using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa)); - SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa"); + var subtitle = _parser.Parse(stream, "ssa"); - Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count); + Assert.Equal(expectedSubtitleTrackEvents.Count, subtitle.Paragraphs.Count); for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i) { SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i]; - SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i]; + var actual = subtitle.Paragraphs[i]; - Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Id, actual.Number.ToString(CultureInfo.InvariantCulture)); Assert.Equal(expected.Text, actual.Text); - Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks); - Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks); + Assert.Equal(expected.StartPositionTicks, actual.StartTime.TimeSpan.Ticks); + Assert.Equal(expected.EndPositionTicks, actual.EndTime.TimeSpan.Ticks); } } @@ -75,13 +75,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests using var stream = File.OpenRead("Test Data/example.ssa"); var parsed = _parser.Parse(stream, "ssa"); - Assert.Single(parsed.TrackEvents); - var trackEvent = parsed.TrackEvents[0]; + Assert.Single(parsed.Paragraphs); + var paragraph = parsed.Paragraphs[0]; - Assert.Equal("1", trackEvent.Id); - Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); - Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text); + Assert.Equal(1, paragraph.Number); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks); + Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", paragraph.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs index ce1f005f40..48850b2f67 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -1,3 +1,8 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using AutoFixture; @@ -6,87 +11,120 @@ using MediaBrowser.MediaEncoding.Subtitles; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.MediaEncoding.Subtitles.Tests { public class SubtitleEncoderTests { - public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData() + private const int StreamCount = 8; + private const int CueCount = 500; + + // A Greek line that requires a non-UTF-8 legacy encoding to reproduce the bug. The accented + // characters (ά, έ, ή, ί, ό, ύ, ώ) share the same code points in windows-1253 and iso-8859-7, + // so a Greek-vs-Greek charset misdetection still round-trips correctly. + private const string GreekText = "Καλημέρα κόσμε, αυτό είναι ένας υπότιτλος."; + + static SubtitleEncoderTests() { - var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>(); + // Mirrors Jellyfin.Server startup so legacy code pages (e.g. Greek windows-1253) are available. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() - { - Path = "/media/sub.ass", - IsExternal = true - }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.ass", - Protocol = MediaProtocol.File, - Format = "ass", - IsExternal = true - }); + // Enough Greek text to give the charset detector a strong, unambiguous signal. + private static string BuildGreekSrt() + { + var builder = new StringBuilder(); + for (var i = 1; i <= 8; i++) + { + builder.Append(i.ToString(CultureInfo.InvariantCulture)).Append('\n'); + builder.Append("00:00:0").Append(i.ToString(CultureInfo.InvariantCulture)) + .Append(",000 --> 00:00:0").Append((i + 1).ToString(CultureInfo.InvariantCulture)).Append(",000\n"); + builder.Append(GreekText).Append('\n'); + builder.Append("Η γρήγορη καφέ αλεπού πηδάει πάνω από το τεμπέλικο σκυλί.\n\n"); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() - { - Path = "/media/sub.ssa", - IsExternal = true - }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.ssa", - Protocol = MediaProtocol.File, - Format = "ssa", - IsExternal = true - }); + return builder.ToString(); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() + public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData() + { + var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> + { { - Path = "/media/sub.srt", - IsExternal = true + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + } }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.srt", - Protocol = MediaProtocol.File, - Format = "srt", - IsExternal = true - }); - - data.Add( - new MediaSourceInfo() { - Protocol = MediaProtocol.Http + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ssa", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ssa", + Protocol = MediaProtocol.File, + Format = "ssa", + IsExternal = true + } }, - new MediaStream() { - Path = "/media/sub.ass", - IsExternal = true + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.srt", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.srt", + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + } }, - new SubtitleEncoder.SubtitleInfo() { - Path = "/media/sub.ass", - Protocol = MediaProtocol.File, - Format = "ass", - IsExternal = true - }); + new MediaSourceInfo() + { + Protocol = MediaProtocol.Http + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + } + } + }; return data; } @@ -103,5 +141,177 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Assert.Equal(subtitleInfo.Format, result.Format); Assert.Equal(subtitleInfo.IsExternal, result.IsExternal); } + + public static TheoryData<Encoding> GetSubtitleStream_NonUtf8LocalFile_TestData() + { + return + [ + // Greek legacy encodings – the exact scenario reported in issue #17267. + Encoding.GetEncoding("windows-1253"), + Encoding.GetEncoding("iso-8859-7"), + // Wide encoding with a BOM. + new UnicodeEncoding(bigEndian: false, byteOrderMark: true), + ]; + } + + [Theory] + [MemberData(nameof(GetSubtitleStream_NonUtf8LocalFile_TestData))] + public async Task GetSubtitleStream_NonUtf8LocalFile_ConvertedToUtf8(Encoding sourceEncoding) + { + var cancellationToken = TestContext.Current.CancellationToken; + var srt = BuildGreekSrt(); + var path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, srt, sourceEncoding, cancellationToken); + + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create<SubtitleEncoder>(); + + var fileInfo = new SubtitleEncoder.SubtitleInfo + { + Path = path, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; + + using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken); + using var reader = new StreamReader(stream, new UTF8Encoding(false)); + var text = await reader.ReadToEndAsync(cancellationToken); + + // The Greek text must survive round-trip and contain no replacement characters. + Assert.Contains(GreekText, text, StringComparison.Ordinal); + Assert.DoesNotContain('�', text); + Assert.DoesNotContain('?', text); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ConvertSubtitles_SequentialCalls_AreDeterministic() + { + using var encoder = CreateEncoder(); + var sources = GenerateSources(); + + var first = ConvertAllSequential(encoder, sources); + var second = ConvertAllSequential(encoder, sources); + + for (var i = 0; i < StreamCount; i++) + { + Assert.Contains($"S{i}C{CueCount - 1}", first[i], StringComparison.Ordinal); + Assert.Equal(first[i], second[i]); + } + } + + [Fact] + public async Task GetSubtitleStream_Utf8LocalFile_PreservesContent() + { + var cancellationToken = TestContext.Current.CancellationToken; + var srt = BuildGreekSrt(); + var path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, srt, new UTF8Encoding(false), cancellationToken); + + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create<SubtitleEncoder>(); + + var fileInfo = new SubtitleEncoder.SubtitleInfo + { + Path = path, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; + + using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken); + + // An already-UTF-8 file must be short-circuited and served directly from disk, + // not read into memory and re-encoded (which would produce a MemoryStream). + Assert.IsNotType<MemoryStream>(stream); + + using var reader = new StreamReader(stream, new UTF8Encoding(false)); + var text = await reader.ReadToEndAsync(cancellationToken); + + Assert.Contains(GreekText, text, StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ConvertSubtitles_ConcurrentCalls_MatchSequentialBaseline() + { + const int Iterations = 10; + + using var encoder = CreateEncoder(); + var sources = GenerateSources(); + var baseline = ConvertAllSequential(encoder, sources); + + for (var iteration = 0; iteration < Iterations; iteration++) + { + var results = await Task.WhenAll(Enumerable.Range(0, StreamCount) + .Select(i => Task.Run(() => Convert(encoder, sources[i], i))) + .ToArray()); + + for (var i = 0; i < StreamCount; i++) + { + Assert.True( + string.Equals(baseline[i], results[i], StringComparison.Ordinal), + $"Iteration {iteration}: stream {i} returned corrupted content ({results[i].Length} chars vs {baseline[i].Length} baseline)"); + } + } + } + + private static SubtitleEncoder CreateEncoder() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + fixture.Inject<ISubtitleParser>(new SubtitleEditParser(NullLogger<SubtitleEditParser>.Instance)); + return fixture.Create<SubtitleEncoder>(); + } + + private static byte[][] GenerateSources() + { + return Enumerable.Range(0, StreamCount) + .Select(i => Encoding.UTF8.GetBytes(GenerateSrt(i, CueCount))) + .ToArray(); + } + + private static string Convert(SubtitleEncoder encoder, byte[] source, int streamIndex) + { + using var input = new MemoryStream(source); + var info = new SubtitleEncoder.SubtitleInfo { Path = $"track{streamIndex}.srt", Format = "srt" }; + using var output = encoder.ConvertSubtitles(input, info, "vtt", 0, 0, false); + return Encoding.UTF8.GetString(output.ToArray()); + } + + private static string[] ConvertAllSequential(SubtitleEncoder encoder, byte[][] sources) + { + return sources.Select((source, i) => Convert(encoder, source, i)).ToArray(); + } + + private static string GenerateSrt(int streamIndex, int cueCount) + { + var builder = new StringBuilder(); + for (var i = 0; i < cueCount; i++) + { + var start = TimeSpan.FromSeconds(i * 4); + var end = start + TimeSpan.FromSeconds(2); + builder.Append(i + 1).AppendLine() + .Append(start.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture)) + .Append(" --> ") + .AppendLine(end.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture)) + .Append('S').Append(streamIndex).Append('C').Append(i).AppendLine() + .AppendLine(); + } + + return builder.ToString(); + } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json index 9a7a4ba373..e406cc18b0 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_mp4_metadata.json @@ -95,7 +95,8 @@ "tags": { "creation_time": "2021-09-13T22:42:42.000000Z", "language": "eng", - "handler_name": "Surround 6.1", + "handler_name": "SoundHandler", + "name": "Surround 6.1", "vendor_id": "[0][0][0][0]" } }, @@ -215,7 +216,8 @@ "tags": { "creation_time": "2021-09-13T22:42:42.000000Z", "language": "eng", - "handler_name": "SubtitleHandler" + "handler_name": "SubtitleHandler", + "name": "SDH" } }, { diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs new file mode 100644 index 0000000000..dfd1eb2e85 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs @@ -0,0 +1,104 @@ +using System; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Model.Tests.Dlna; + +public class StreamBuilderManifestContainerTests +{ + [Theory] + // A manifest describes a stream instead of carrying one, so it can never be direct played, + // even when the client claims to support the container. + [InlineData("hls")] + [InlineData("hls,applehttp")] + [InlineData("applehttp")] + [InlineData("dash")] + public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container) + { + var streamInfo = BuildFor(container); + + Assert.NotNull(streamInfo); + Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod); + } + + [Fact] + public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays() + { + var streamInfo = BuildFor("mp4"); + + Assert.NotNull(streamInfo); + Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod); + } + + private static StreamInfo? BuildFor(string container) + { + var mediaSource = new MediaSourceInfo + { + Id = "test-source", + Path = "http://example.com/live/channel", + Protocol = MediaProtocol.Http, + Container = container, + SupportsDirectPlay = true, + SupportsDirectStream = true, + SupportsTranscoding = true, + IsInfiniteStream = true, + IsRemote = true, + MediaStreams = + [ + new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" }, + new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" } + ] + }; + + var profile = new DeviceProfile + { + Name = "Manifest aware client", + DirectPlayProfiles = + [ + new DirectPlayProfile + { + Type = DlnaProfileType.Video, + Container = "mp4,hls,applehttp,dash", + VideoCodec = "h264", + AudioCodec = "aac" + } + ], + TranscodingProfiles = + [ + new TranscodingProfile + { + Type = DlnaProfileType.Video, + Context = EncodingContext.Streaming, + Protocol = MediaStreamProtocol.hls, + Container = "ts", + VideoCodec = "h264", + AudioCodec = "aac" + } + ] + }; + + var options = new MediaOptions + { + ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"), + MediaSourceId = mediaSource.Id, + MediaSources = [mediaSource], + DeviceId = "test-deviceId", + Profile = profile, + AllowAudioStreamCopy = true, + AllowVideoStreamCopy = true, + EnableDirectStream = false // This is disabled in server + }; + + var transcodeSupport = new Mock<ITranscoderSupport>(); + + return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>()) + .GetOptimalVideoStream(options); + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index d94d56bc20..f5a023686c 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -371,6 +371,45 @@ namespace Jellyfin.Model.Tests Assert.Equal(streamInfo?.SubtitleStreamIndex, options.SubtitleStreamIndex); } + [Theory] + [InlineData("pgssub", null)] + [InlineData("vobsub", "mks")] + public async Task BuildVideoItemWithSecondaryAudioAndExternalGraphicalSubtitleKeepsVideoCopy(string subtitleCodec, string? subtitleContainer) + { + var options = await GetMediaOptions("Chrome", "mp4-h264-ac3-aac-srt-2600k"); + var subtitleStream = options.MediaSources[0].MediaStreams[^1]; + subtitleStream.Codec = subtitleCodec; + subtitleStream.IsExternal = false; + subtitleStream.SupportsExternalStream = true; + subtitleStream.Path = null; + + options.Profile.SubtitleProfiles = + [ + new SubtitleProfile + { + Format = subtitleCodec, + Container = subtitleContainer, + Method = SubtitleDeliveryMethod.External + } + ]; + options.AudioStreamIndex = 2; + options.SubtitleStreamIndex = subtitleStream.Index; + + var streamInfo = GetStreamBuilder(enableSubtitleExtraction: false).GetOptimalVideoStream(options); + + Assert.NotNull(streamInfo); + Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod); + Assert.Equal(TranscodeReason.SecondaryAudioNotSupported, streamInfo.TranscodeReasons); + Assert.Equal(SubtitleDeliveryMethod.External, streamInfo.SubtitleDeliveryMethod); + Assert.Contains("h264", streamInfo.VideoCodecs); + Assert.Contains("aac", streamInfo.AudioCodecs); + + var queryString = streamInfo.ToUrl("media:", "ACCESSTOKEN", null).Split('?', 2).ElementAtOrDefault(1); + var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty); + Assert.Null(query["SubtitleStreamIndex"]); + Assert.Null(query["SubtitleMethod"]); + } + private StreamInfo? BuildVideoItemSimpleTest(MediaOptions options, PlayMethod? playMethod, TranscodeReason why, string transcodeMode, string transcodeProtocol) { if (string.IsNullOrEmpty(transcodeProtocol)) @@ -573,9 +612,10 @@ namespace Jellyfin.Model.Tests throw new SerializationException("Invalid test data: " + name); } - private StreamBuilder GetStreamBuilder() + private StreamBuilder GetStreamBuilder(bool enableSubtitleExtraction = false) { var transcodeSupport = new Mock<ITranscoderSupport>(); + transcodeSupport.Setup(t => t.CanExtractSubtitles(It.IsAny<string>())).Returns(enableSubtitleExtraction); var logger = new NullLogger<StreamBuilderTests>(); return new StreamBuilder(transcodeSupport.Object, logger); @@ -625,7 +665,7 @@ namespace Jellyfin.Model.Tests // EnableSubtitleExtraction = false, internal subtitles [InlineData("srt", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] [InlineData("srt", "srt", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)] - [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] + [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.External)] [InlineData("pgssub", "pgssub", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)] [InlineData("pgssub", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] // EnableSubtitleExtraction = false, external subtitles @@ -677,6 +717,48 @@ namespace Jellyfin.Model.Tests } [Theory] + [InlineData(false, null, true, SubtitleDeliveryMethod.External)] + [InlineData(false, null, false, SubtitleDeliveryMethod.External)] + [InlineData(true, "/media/sub.mks", true, SubtitleDeliveryMethod.External)] + [InlineData(true, "/media/sub.idx", true, SubtitleDeliveryMethod.Encode)] + [InlineData(true, "/media/sub.sub", true, SubtitleDeliveryMethod.Encode)] + public void GetSubtitleProfile_MatchesVobSubMksProfileOnlyWhenDeliveredAsMks( + bool isExternal, + string? path, + bool enableSubtitleExtraction, + SubtitleDeliveryMethod expectedMethod) + { + var mediaSource = new MediaSourceInfo(); + var subtitleStream = new MediaStream + { + Type = MediaStreamType.Subtitle, + Index = 0, + IsExternal = isExternal, + Path = path, + Codec = "vobsub" + }; + + var subtitleProfiles = new[] + { + new SubtitleProfile { Format = "vobsub", Container = "mks", Method = SubtitleDeliveryMethod.External } + }; + + var transcoderSupport = new Mock<ITranscoderSupport>(); + transcoderSupport.Setup(t => t.CanExtractSubtitles(It.IsAny<string>())).Returns(enableSubtitleExtraction); + + var result = StreamBuilder.GetSubtitleProfile( + mediaSource, + subtitleStream, + subtitleProfiles, + PlayMethod.Transcode, + transcoderSupport.Object, + null, + null); + + Assert.Equal(expectedMethod, result.Method); + } + + [Theory] // External text subs embedded into MKV when transcoding (#16403) [InlineData("srt", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] [InlineData("ass", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] diff --git a/tests/Jellyfin.Model.Tests/Drawing/DrawingUtilsTests.cs b/tests/Jellyfin.Model.Tests/Drawing/DrawingUtilsTests.cs new file mode 100644 index 0000000000..473b07a8a1 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Drawing/DrawingUtilsTests.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Model.Drawing; +using Xunit; + +namespace Jellyfin.Model.Drawing; + +public static class DrawingUtilsTests +{ + [Theory] + // Already inside the box, returned untouched. + [InlineData(600, 336, 1920, 1080, 600, 336)] + [InlineData(1920, 1080, 1920, 1080, 1920, 1080)] + // Scaled down uniformly, requested aspect ratio preserved. + [InlineData(23100, 23100, 1920, 1080, 1080, 1080)] + [InlineData(3840, 2160, 1920, 1080, 1920, 1080)] + [InlineData(1200, 400, 600, 336, 600, 200)] + // Extreme ratios still produce at least one pixel per axis. + [InlineData(10000, 1, 100, 100, 100, 1)] + // Degenerate inputs are passed through rather than dividing by zero. + [InlineData(600, 336, 0, 0, 600, 336)] + [InlineData(0, 0, 1920, 1080, 0, 0)] + public static void ScaleDownToFit_Bounds_WithoutUpscaling(int width, int height, int boxWidth, int boxHeight, int expectedWidth, int expectedHeight) + { + var scaled = DrawingUtils.ScaleDownToFit(new ImageDimensions(width, height), new ImageDimensions(boxWidth, boxHeight)); + + Assert.Equal(expectedWidth, scaled.Width); + Assert.Equal(expectedHeight, scaled.Height); + } +} diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs new file mode 100644 index 0000000000..f264e5a019 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs @@ -0,0 +1,129 @@ +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Model.Tests.Entities; + +public class MediaStreamVideoRangeTests +{ + [Theory] + [InlineData(7, 6, "smpte2084", false, VideoRangeType.DOVIWithEL)] + [InlineData(7, 6, "smpte2084", true, VideoRangeType.DOVIWithELHDR10Plus)] + [InlineData(8, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)] + [InlineData(8, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)] + [InlineData(8, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)] + [InlineData(10, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)] + [InlineData(10, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)] + [InlineData(10, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)] + [InlineData(8, 1, "SMPTE2084", false, VideoRangeType.DOVIWithHDR10)] + [InlineData(8, 4, "ARIB-STD-B67", false, VideoRangeType.DOVIWithHLG)] + public void GetVideoColorRange_ValidDovi_PreservesRangeType( + int profile, int compatibilityId, string transfer, bool hdr10Plus, VideoRangeType expected) + { + var stream = CreateDovi(profile, compatibilityId, "BT2020NC", transfer, "BT2020", hdr10Plus); + + Assert.Equal((VideoRange.HDR, expected), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData("bt709", "bt709", "bt709", VideoRange.SDR)] + [InlineData("bt2020nc", "bt709", "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", null, "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", "", "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", "unknown", "bt2020", VideoRange.SDR)] + [InlineData("bt2020nc", "bt2020-10", "bt2020", VideoRange.SDR)] + [InlineData(null, null, null, VideoRange.SDR)] + [InlineData("bt709", "smpte2084", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "smpte2084", "bt709", VideoRange.HDR)] + [InlineData(null, "smpte2084", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "smpte2084", null, VideoRange.HDR)] + [InlineData("bt709", "arib-std-b67", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "arib-std-b67", "bt709", VideoRange.HDR)] + [InlineData(null, "arib-std-b67", "bt2020", VideoRange.HDR)] + [InlineData("bt2020nc", "arib-std-b67", null, VideoRange.HDR)] + public void GetVideoColorRange_InvalidDoviColors_UsesBaseLayerRange( + string? space, string? transfer, string? primaries, VideoRange expected) + { + // Cover every HDR-compatible DV profile, including the HDR10+ variants. + foreach (var (profile, compatibilityId) in new[] { (7, 6), (8, 1), (8, 4), (10, 1), (10, 4) }) + { + foreach (var hdr10Plus in new[] { false, true }) + { + var stream = CreateDovi(profile, compatibilityId, space, transfer, primaries, hdr10Plus); + + Assert.Equal(expected, stream.VideoRange); + Assert.Equal(VideoRangeType.DOVIInvalid, stream.VideoRangeType); + } + } + } + + [Theory] + [InlineData(7, 6, "arib-std-b67")] + [InlineData(8, 1, "arib-std-b67")] + [InlineData(8, 4, "smpte2084")] + [InlineData(10, 1, "arib-std-b67")] + [InlineData(10, 4, "smpte2084")] + public void GetVideoColorRange_WrongHdrTransfer_InvalidButStillHdr(int profile, int compatibilityId, string transfer) + { + var stream = CreateDovi(profile, compatibilityId, "bt2020nc", transfer, "bt2020", true); + + Assert.Equal((VideoRange.HDR, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData(5, 0, null, VideoRange.HDR, VideoRangeType.DOVI)] + [InlineData(10, 0, null, VideoRange.HDR, VideoRangeType.DOVI)] + [InlineData(8, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)] + [InlineData(10, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)] + public void GetVideoColorRange_OtherDoviProfiles_PreservesClassification( + int profile, int compatibilityId, string? transfer, VideoRange range, VideoRangeType rangeType) + { + var stream = CreateDovi(profile, compatibilityId, "bt709", transfer, "bt709", false); + + Assert.Equal((range, rangeType), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData(8, null, VideoRange.SDR)] + [InlineData(8, "bt709", VideoRange.SDR)] + [InlineData(8, "smpte2084", VideoRange.HDR)] + [InlineData(10, null, VideoRange.SDR)] + [InlineData(10, "arib-std-b67", VideoRange.HDR)] + public void GetVideoColorRange_InvalidCompatibilityId_UsesBaseLayerRange(int profile, string? transfer, VideoRange expected) + { + var stream = CreateDovi(profile, 6, "bt2020nc", transfer, "bt2020", false); + + Assert.Equal((expected, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange()); + } + + [Theory] + [InlineData("bt709", false, VideoRange.SDR, VideoRangeType.SDR)] + [InlineData(null, false, VideoRange.SDR, VideoRangeType.SDR)] + [InlineData("smpte2084", false, VideoRange.HDR, VideoRangeType.HDR10)] + [InlineData("smpte2084", true, VideoRange.HDR, VideoRangeType.HDR10Plus)] + [InlineData("arib-std-b67", false, VideoRange.HDR, VideoRangeType.HLG)] + public void GetVideoColorRange_WithoutDovi_PreservesClassification( + string? transfer, bool hdr10Plus, VideoRange range, VideoRangeType rangeType) + { + var stream = new MediaStream { Type = MediaStreamType.Video, ColorTransfer = transfer, Hdr10PlusPresentFlag = hdr10Plus }; + + Assert.Equal((range, rangeType), stream.GetVideoColorRange()); + stream.Type = MediaStreamType.Audio; + Assert.Equal((VideoRange.Unknown, VideoRangeType.Unknown), stream.GetVideoColorRange()); + } + + private static MediaStream CreateDovi(int profile, int compatibilityId, string? space, string? transfer, string? primaries, bool hdr10Plus) + => new() + { + Type = MediaStreamType.Video, + DvProfile = profile, + DvBlSignalCompatibilityId = compatibilityId, + RpuPresentFlag = 1, + BlPresentFlag = 1, + ElPresentFlag = profile == 7 ? 1 : 0, + ColorSpace = space, + ColorTransfer = transfer, + ColorPrimaries = primaries, + Hdr10PlusPresentFlag = hdr10Plus + }; +} diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs index a6f4164144..2347c08961 100644 --- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs @@ -186,6 +186,109 @@ namespace Jellyfin.Model.Tests.Entities Assert.Null(nullProvider.ProviderIds); } + [Theory] + [InlineData(nameof(MetadataProvider.Imdb), "tt0113375", true)] + [InlineData(nameof(MetadataProvider.Imdb), "nm0000123", true)] + [InlineData(nameof(MetadataProvider.Imdb), "0113375", true)] + [InlineData(nameof(MetadataProvider.Imdb), "https://www.imdb.com/title/tt0113375", false)] + [InlineData(nameof(MetadataProvider.Tmdb), "11", true)] + [InlineData(nameof(MetadataProvider.Tmdb), "nm0000123", false)] + [InlineData(nameof(MetadataProvider.Tmdb), "0", false)] + [InlineData(nameof(MetadataProvider.Tmdb), "-11", false)] + [InlineData(nameof(MetadataProvider.TmdbCollection), "nm0000123", false)] + [InlineData(nameof(MetadataProvider.AudioDbArtist), "111239", true)] + [InlineData(nameof(MetadataProvider.AudioDbArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", false)] + [InlineData(nameof(MetadataProvider.MusicBrainzArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", true)] + [InlineData(nameof(MetadataProvider.MusicBrainzArtist), "111239", false)] + [InlineData(nameof(MetadataProvider.MusicBrainzAlbum), "not-an-mbid", false)] + [InlineData(nameof(MetadataProvider.Tvdb), "anything-goes", true)] + [InlineData("SomePlugin", "anything-goes", true)] + [InlineData(nameof(MetadataProvider.Tmdb), null, false)] + [InlineData(null, "11", false)] + public void IsValidProviderId_ChecksKnownFormats(string? name, string? value, bool expected) + { + Assert.Equal(expected, ProviderIdsExtensions.IsValidProviderId(name, value)); + } + + [Fact] + public void TrySetProviderId_ForeignId_False() + { + var provider = new ProviderIdsExtensionsTestsObject(); + + Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123")); + Assert.Empty(provider.ProviderIds); + } + + [Fact] + public void TrySetProviderId_ForeignId_KeepsExisting() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Tmdb.ToString()] = "11"; + + Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123")); + Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb)); + } + + [Theory] + [InlineData(nameof(MetadataProvider.Imdb), " tt0113375 ")] + [InlineData(" Imdb", ExampleImdbId)] + public void TrySetProviderId_SurroundingWhitespace_Trimmed(string name, string value) + { + var provider = new ProviderIdsExtensionsTestsObject(); + + Assert.True(provider.TrySetProviderId(name, value)); + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void SetProviderIds_ReplacesAll() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Tvdb.ToString()] = "12345"; + + provider.SetProviderIds(new Dictionary<string, string> + { + [MetadataProvider.Imdb.ToString()] = ExampleImdbId + }); + + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + Assert.False(provider.HasProviderId(MetadataProvider.Tvdb)); + } + + [Fact] + public void SetProviderIds_ForeignId_Dropped() + { + var provider = new ProviderIdsExtensionsTestsObject(); + + provider.SetProviderIds(new Dictionary<string, string> + { + [MetadataProvider.Tmdb.ToString()] = "nm0000123", + [MetadataProvider.Imdb.ToString()] = ExampleImdbId, + [MetadataProvider.Tvdb.ToString()] = string.Empty + }); + + Assert.False(provider.HasProviderId(MetadataProvider.Tmdb)); + Assert.False(provider.HasProviderId(MetadataProvider.Tvdb)); + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void SetProviderIds_Null_Clears() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + + provider.SetProviderIds(null); + + Assert.Empty(provider.ProviderIds); + } + + [Fact] + public void SetProviderIds_NullInstance_ThrowsArgumentNullException() + { + Assert.Throws<ArgumentNullException>(() => ProviderIdsExtensions.SetProviderIds(null!, new Dictionary<string, string>())); + } + [Fact] public void RemoveProviderId_Null_Remove() { diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs index b441e49b19..4236749423 100644 --- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs @@ -69,6 +69,14 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 1/series-s09e14-720i.mkv", null)] [InlineData("Season 1/MOONLIGHTING_s01e01-e04.mkv", 4)] [InlineData("Season 1/MOONLIGHTING_s01e01-e04", 4)] + // Hyphenated numbers in the episode title must not be read as an episode range + [InlineData("Season 1/S01E01 The 6-10 to Lubbock [WEBRip-1080p][AV1 Opus].mkv", null)] + [InlineData("Season 5/S05E23 11-59 [HDTV-1080p][x265 AC3].mkv", null)] + [InlineData("Season 5/S05E23 11-59 [HDTV-1080p][HEVC AC3].mkv", null)] + [InlineData("Season 1/S01E01 1-23-45 [Bluray-1080p][AV1 Opus].mkv", null)] + // Episode markers in the episode title must not be read as an episode range + [InlineData("Season 03/Star Trek Enterprise (2001) - S03E21 - E2 (1080p BluRay x265).mkv", null)] + [InlineData("Season 02/Series Name (2001) - S02E10 - E5 [WEBRip-1080p].mkv", null)] public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber) { var result = _episodePathParser.Parse(filename, false); diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs index b81b7934cd..023c6cb2fa 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs @@ -20,6 +20,11 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/some/path/The Show s02e10 720p hdtv", "The Show")] [InlineData("/some/path/The Show s02e10 the episode 720p hdtv", "The Show")] [InlineData("/some/path/1923 (2022)", "1923")] + // A dotted acronym keeps its dots when it follows words, whether they are space or dot separated + [InlineData("/some/path/Marvel's Agents of S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")] + [InlineData("Marvel's.Agents.of.S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")] + [InlineData("The.Show.S.H.O.W", "The Show S.H.O.W")] + [InlineData("/some/path/Dawson's Creek", "Dawson's Creek")] public void SeriesResolverResolveTest(string path, string name) { var res = SeriesResolver.Resolve(_namingOptions, path); diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs index df5819d747..8b4a6ff4e4 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanStringTests.cs @@ -23,6 +23,7 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("Crouching.Tiger.Hidden.Dragon.BDrip.mkv", "Crouching.Tiger.Hidden.Dragon")] [InlineData("Crouching.Tiger.Hidden.Dragon.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")] [InlineData("Crouching.Tiger.Hidden.Dragon.4K.UltraHD.HDR.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")] + [InlineData("Last.Call.for.Nowhere.WEB-DL.1080p", "Last.Call.for.Nowhere")] [InlineData("[HorribleSubs] Made in Abyss - 13 [720p].mkv", "Made in Abyss")] [InlineData("[Tsundere] Kore wa Zombie Desu ka of the Dead [BDRip h264 1920x1080 FLAC]", "Kore wa Zombie Desu ka of the Dead")] [InlineData("[Erai-raws] Jujutsu Kaisen - 03 [720p][Multiple Subtitle].mkv", "Jujutsu Kaisen")] diff --git a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs index d42bd66a1c..0e35071dd4 100644 --- a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs @@ -20,6 +20,27 @@ namespace Jellyfin.Naming.Tests.Video } [Fact] + public void TestFormat3DAtEndOfPath() + { + // Directory based media (eg. DVD or BluRay folder rips) have no file extension, + // so the 3D tag can be the last token of the path. + Test("Super movie (2009) 3d hsbs", true, "hsbs"); + Test("Super movie (2009).3d.sbs", true, "sbs"); + Test("Super movie (2009) 3d htab", true, "htab"); + Test("Super movie (2009).hsbs", true, "hsbs"); + Test("Super movie (2009) 3d", false, null); + } + + [Fact] + public void TestResolveDirectory3D() + { + var result = VideoResolver.ResolveDirectory("/movies/Oblivion (2013) 3d hsbs", _namingOptions); + + Assert.True(result?.Is3D); + Assert.Equal("hsbs", result?.Format3D, true); + } + + [Fact] public void Test3DName() { var result = VideoResolver.ResolveFile("C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions); diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index b29c64f50d..62c1cf25d7 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -1130,5 +1130,45 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(2, result[0].Files.Count); Assert.Single(result[0].AlternateVersions); } + + [Fact] + public void TestMultiVersionEpisodeAbsoluteNumberingWithNumberInSeriesTitle() + { + // Every one of these parses as episode 2, because the expressions read the "2" of the + // series title as an absolute episode number. They are distinct episodes all the same. + var files = new[] + { + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 01 - The Memory of a Summer (b6f40849).mkv", + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 02 - Heart Pain Killer (d8c0896c).mkv", + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 03 - Translucent Chord (4ecce3dd).mkv", + "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 04 - The Mysterious Lady (837a1909).mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(4, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeAbsoluteNumberingDontCollapse() + { + // Plain absolute numbering: no season number is available, so the files stay separate. + var files = new[] + { + "/anime/Bleach/Bleach - 001 - The Day I Became a Shinigami.mkv", + "/anime/Bleach/Bleach - 002 - The Shinigami's Work.mkv", + "/anime/Bleach/Bleach - 003 - The Older Brother's Wish.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(3, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } } } diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 1f523f7f21..d8cb9e1ac6 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -6,6 +6,7 @@ using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -493,5 +494,219 @@ namespace Jellyfin.Networking.Tests Assert.Equal(result, interfaceToUse); } + + [Theory] + // Internal override with an explicit port. + [InlineData("192.168.1.1", "192.168.1.0/24=internal.jellyfin:8097", "internal.jellyfin", 8097)] + // External/all override with an explicit port. + [InlineData("8.8.8.8", "all=external.jellyfin:8097", "external.jellyfin", 8097)] + // Bracketed IPv6 override with an explicit port. + [InlineData("8.8.8.8", "all=[fd00:1234::1]:8097", "fd00:1234::1", 8097)] + // Bare IPv6 override without a port - must remain whole, not mangled by the extra colons. + [InlineData("8.8.8.8", "all=fd00:1234::1", "fd00:1234::1", null)] + // Full HTTPS URL override with an explicit port - the URL stays whole, port stays embedded. + [InlineData("8.8.8.8", "all=https://secure.jellyfin.org:8920", "https://secure.jellyfin.org:8920", null)] + // Hostname beginning with "http" is a hostname, not a URL scheme. + [InlineData("8.8.8.8", "all=http-proxy.lan:8097", "http-proxy.lan", 8097)] + // Literal "internal" keyword override (applies to every LAN subnet) with an explicit port. + [InlineData("192.168.1.1", "internal=myhost.internal:8097", "myhost.internal", 8097)] + // Literal "external" keyword override with an explicit port. + [InlineData("8.8.8.8", "external=myhost.external:9090", "myhost.external", 9090)] + public void GetBindAddress_PublishedServerOverride_ParsesHostAndPort(string source, string publishedServers, string expectedHost, int? expectedPort) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var intf = nm.GetBindAddress(IPAddress.Parse(source), out int? port); + + Assert.Equal(expectedHost, intf); + Assert.Equal(expectedPort, port); + } + + /// <summary> + /// Regression coverage for <c>IServerApplicationHost.GetApiUrlForLocalAccess()</c>, which calls + /// <see cref="NetworkManager.GetBindAddress(IPAddress, out int?, bool)"/> with a null source address. + /// Published server URL overrides are only matched when a source address is supplied + /// (<c>MatchesPublishedServerUrl</c> requires it), so a null source must never come back as a published + /// CLI/dashboard URL - it must fall back to a plain local bind address. + /// </summary> + [Fact] + public void GetBindAddress_NullSource_DoesNotApplyPublishedServerOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "all=http://published.example.com" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var result = nm.GetBindAddress((IPAddress?)null, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Null(port); + } + + [Theory] + // Full-URL override with a different public path: warn about the Live TV fallback. + [InlineData("all=https://media.example.com", "/jellyfin", true)] + // Full-URL override that ends with the base URL (with and without a trailing slash): no warning. + [InlineData("all=https://media.example.com/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/jellyfin/", "/jellyfin", false)] + [InlineData("all=https://media.example.com/media/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/cool%20server", "/cool server", false)] + // A similar segment or a path following the base URL is a different public API base. + [InlineData("all=https://media.example.com/jellyfinx", "/jellyfin", true)] + [InlineData("all=https://media.example.com/jellyfin/media", "/jellyfin", true)] + // No base URL configured: there is no path to compare. + [InlineData("all=https://media.example.com", "", false)] + // Bare host overrides get the base URL appended when the API URL is built: no warning. + [InlineData("all=media.example.com", "/jellyfin", false)] + [InlineData("internal=http-proxy.lan:8097", "/jellyfin", false)] + // Keyword overrides go through the same check as "all". + [InlineData("internal=http://10.0.0.5:8096", "/jellyfin", true)] + public void InitializeOverrides_FullUrlPublicPathDiffersFromBaseUrl_LogsWarning(string publishedServers, string baseUrl, bool expectWarning) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers }, + BaseUrl = baseUrl + }; + + var logger = new Mock<ILogger<NetworkManager>>(); + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, expectWarning ? Times.AtLeastOnce() : Times.Never()); + } + + /// <summary> + /// The JELLYFIN_PublishedServerUrl environment variable / --published-server-url option takes the + /// startup-configuration branch of <c>InitializeOverrides</c> and must funnel through the same + /// base URL check as the dashboard overrides. + /// </summary> + [Fact] + public void InitializeOverrides_StartupPublishedServerUrlPathDiffersFromBaseUrl_LogsWarningWithoutCredentials() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + BaseUrl = "/jellyfin" + }; + + var logger = new Mock<ILogger<NetworkManager>>(); + var startupConf = new Mock<IConfiguration>(); + startupConf.Setup(x => x[MediaBrowser.Controller.Extensions.ConfigurationExtensions.AddressOverrideKey]).Returns("https://user:password@media.example.com?access_token=secret#fragment"); + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, Times.AtLeastOnce()); + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("user", StringComparison.Ordinal) + || state.ToString()!.Contains("password", StringComparison.Ordinal) + || state.ToString()!.Contains("access_token", StringComparison.Ordinal) + || state.ToString()!.Contains("secret", StringComparison.Ordinal) + || state.ToString()!.Contains("fragment", StringComparison.Ordinal)), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never()); + } + + private static void VerifyBaseUrlWarning(Mock<ILogger<NetworkManager>> logger, Times times) + { + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("Jellyfin will append this base URL when generating Live TV client URLs", StringComparison.Ordinal)), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + times); + } + + /// <summary> + /// <see cref="NetworkManager.GetBindAddress(HttpRequest, out int?)"/> is the piece of request-host + /// normalization that a request-host-aware smart API URL policy relies on: it resolves the bind address + /// from the request's host and falls back to the request's own port when no override applies. + /// </summary> + [Fact] + public void GetBindAddress_HttpRequestOverload_FallsBackToRequestPortWhenNoOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Equal(34567, port); + } + + /// <summary> + /// Ordering check: a dashboard published-server-URL override's explicit port takes precedence over the + /// request's own port, even though the request's host chose which override subnet matched. + /// </summary> + [Fact] + public void GetBindAddress_HttpRequestOverload_PublishedOverridePortWinsOverRequestPort() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "internal=myhost.internal:9000" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("myhost.internal", result); + Assert.Equal(9000, port); + } } } diff --git a/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs new file mode 100644 index 0000000000..f7f29d9768 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Books.ComicBookInfo; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Books; + +public sealed class ComicBookInfoProviderTests : IDisposable +{ + private const string ValidComment = """ + {"appID":"test","ComicBookInfo/1.0":{"series":"Jungle Juice","title":"Episode 36","issue":175}} + """; + + private readonly string _directory; + + public ComicBookInfoProviderTests() + { + _directory = Path.Combine(Path.GetTempPath(), "jf-cbz-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + public void Dispose() + { + Directory.Delete(_directory, true); + } + + [Theory] + [InlineData(null)] // archive written without ever touching Comment + [InlineData("")] + [InlineData(" ")] + public async Task ReadMetadata_EmptyArchiveComment_SkipsWithoutDeserializing(string? comment) + { + var logger = new Mock<ILogger<ComicBookInfoProvider>>(); + var path = CreateArchive(comment); + var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object); + + var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None); + + Assert.False(result.HasMetadata); + VerifyLogged(logger, LogLevel.Debug, "missing ComicBookInfo in archive comment"); + VerifyNothingLoggedAbove(logger, LogLevel.Debug); + } + + [Fact] + public async Task ReadMetadata_ArchiveCommentIsNotComicBookInfo_SkipsWithoutError() + { + var logger = new Mock<ILogger<ComicBookInfoProvider>>(); + var path = CreateArchive("Created by some packer"); + var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object); + + var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None); + + Assert.False(result.HasMetadata); + VerifyLogged(logger, LogLevel.Debug, "archive comment is not valid ComicBookInfo metadata"); + VerifyNothingLoggedAbove(logger, LogLevel.Debug); + } + + [Fact] + public async Task ReadMetadata_ValidComicBookInfoComment_ReturnsMetadata() + { + var logger = new Mock<ILogger<ComicBookInfoProvider>>(); + var path = CreateArchive(ValidComment); + var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object); + + var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None); + + Assert.True(result.HasMetadata); + Assert.NotNull(result.Item); + Assert.Equal("Episode 36", result.Item.Name); + Assert.Equal("Jungle Juice", result.Item.SeriesName); + Assert.Equal(175, result.Item.IndexNumber); + VerifyNothingLoggedAbove(logger, LogLevel.Debug); + } + + private static void VerifyLogged(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level, string message) + { + logger.Verify( + x => x.Log( + level, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains(message, StringComparison.Ordinal)), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Once); + } + + private static void VerifyNothingLoggedAbove(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level) + { + // a comment that holds no ComicBookInfo is normal, so it must not reach the log of a default install + logger.Verify( + x => x.Log( + It.Is<LogLevel>(actual => actual > level), + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + private static IFileSystem CreateFileSystem(string path) + { + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(x => x.GetFileSystemInfo(path)) + .Returns(new FileSystemMetadata + { + Exists = true, + FullName = path, + Name = Path.GetFileName(path), + Extension = ".cbz", + IsDirectory = false + }); + + return fileSystem.Object; + } + + private string CreateArchive(string? comment) + { + var path = Path.Combine(_directory, Guid.NewGuid().ToString("N") + ".cbz"); + + using (var stream = File.Create(path)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create)) + { + if (comment is not null) + { + archive.Comment = comment; + } + + var entry = archive.CreateEntry("ComicInfo.xml"); + using var writer = new StreamWriter(entry.Open()); + writer.Write("<ComicInfo />"); + } + + return path; + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index 1ec859223e..459973acba 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -557,6 +557,47 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal(expectedToUpdate, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); } + [Fact] + public async Task RefreshImages_ProviderDynamicThrows_CountsAFailure() + { + var item = GetItemWithImages(ImageType.Primary, 0, false); + var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1); + + var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict); + dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider"); + dynamicProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { ImageType.Primary }); + dynamicProvider.Setup(rp => rp.GetImage(item, ImageType.Primary, It.IsAny<CancellationToken>())) + .ThrowsAsync(new InvalidOperationException("provider is broken")); + + var itemImageProvider = GetItemImageProvider(null, new Mock<IFileSystem>()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None); + + // Without this the caller stamps DateLastRefreshed and never asks this provider again. + Assert.Equal(1, result.Failures); + } + + [Fact] + public async Task RefreshImages_ProviderRemoteThrows_CountsAFailure() + { + var item = GetItemWithImages(ImageType.Primary, 0, false); + var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1); + + var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { ImageType.Primary }); + + var providerManager = new Mock<IProviderManager>(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>())) + .ThrowsAsync(new HttpRequestException("unreachable")); + + var itemImageProvider = GetItemImageProvider(providerManager.Object, new Mock<IFileSystem>()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None); + + Assert.Equal(1, result.Failures); + } + private static ItemImageProvider GetItemImageProvider(IProviderManager? providerManager, Mock<IFileSystem>? mockFileSystem) { // strict to ensure this isn't accidentally used where a prepared mock is intended diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs new file mode 100644 index 0000000000..d3b0b47465 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs @@ -0,0 +1,38 @@ +using MediaBrowser.Providers.Manager; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class MetadataLanguageUtilsTests + { + [Theory] + [InlineData("es", "es")] + [InlineData("es-ES", "es")] + [InlineData("pt-BR", "pt")] + [InlineData("ES", "es")] + [InlineData(null, null)] + [InlineData("", null)] + public void GetLanguageSubtag_ReturnsLowercasedSubtag(string? language, string? expected) + { + Assert.Equal(expected, MetadataLanguageUtils.GetLanguageSubtag(language)); + } + + [Theory] + [InlineData("es", "es", true)] + [InlineData("es", "es-ES", true)] + [InlineData("es-MX", "es-ES", true)] + [InlineData("ES", "es", true)] + [InlineData("en", "en", true)] + [InlineData("en", "es-ES", false)] + [InlineData("en", "es", false)] + // An unknown language on either side cannot be judged and is assumed to match + [InlineData(null, "es", true)] + [InlineData("", "es", true)] + [InlineData("en", null, true)] + [InlineData("en", "", true)] + public void MatchesPreferredLanguage_ComparesLanguageSubtag(string? resultLanguage, string? preferredLanguage, bool expected) + { + Assert.Equal(expected, MetadataLanguageUtils.MatchesPreferredLanguage(resultLanguage, preferredLanguage)); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs new file mode 100644 index 0000000000..acf8de4366 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Manager; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class MetadataServiceRefreshTests + { + [Theory] + // RemoveOldMetadata is only ever set by an explicit user action - a refresh with "replace all + // metadata", or Identify. A provider failing must not silently downgrade that to a merge: the + // providers that did answer supplied the replacement, and the old values are the wrong match + // the user asked to get rid of. + [InlineData(false)] + [InlineData(true)] + public async Task RefreshWithProviders_ReplaceAllMetadata_ErasesOldDataWhenAProviderAnswers(bool allProvidersSucceed) + { + var item = new Movie + { + Name = "Test Movie", + Overview = "existing overview" + }; + + // The provider owning the overview fails, so it contributes nothing to the replacement. + var failing = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose); + failing.Setup(p => p.Name).Returns("Failing"); + failing.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>())) + .Returns(allProvidersSucceed + ? Task.FromResult(new MetadataResult<Movie> { HasMetadata = true, Item = new Movie() }) + : Task.FromException<MetadataResult<Movie>>(new FormatException("bad id"))); + + var succeeding = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose); + succeeding.Setup(p => p.Name).Returns("Succeeding"); + succeeding.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new MetadataResult<Movie> + { + HasMetadata = true, + Item = new Movie { Name = "Test Movie", Tagline = "new tagline" } + }); + + var service = new TestMetadataService(); + var result = await service.RefreshWithProvidersInternal( + new MetadataResult<Movie> { Item = item }, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + RemoveOldMetadata = true + }, + [failing.Object, succeeding.Object]).ConfigureAwait(true); + + Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures); + Assert.Equal("new tagline", item.Tagline); + Assert.Null(item.Overview); + } + + [Fact] + public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataWhenEveryRemoteProviderFails() + { + var item = new Movie + { + Name = "Test Movie", + Overview = "existing overview" + }; + + // Something has to contribute for the merge to run at all, otherwise the item is never touched + // and the case is moot. The local provider is the replacement the remote ones did not deliver. + var local = new Mock<ILocalMetadataProvider<Movie>>(MockBehavior.Loose); + local.Setup(p => p.Name).Returns("Local"); + local.Setup(p => p.GetMetadata(It.IsAny<ItemInfo>(), It.IsAny<IDirectoryService>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new MetadataResult<Movie> + { + HasMetadata = true, + Item = new Movie { Name = "Test Movie", Tagline = "new tagline" } + }); + + var remote = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose); + remote.Setup(p => p.Name).Returns("Failing"); + remote.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>())) + .Returns(Task.FromException<MetadataResult<Movie>>(new HttpRequestException("unreachable"))); + + var service = new TestMetadataService(); + var result = await service.RefreshWithProvidersInternal( + new MetadataResult<Movie> { Item = item }, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + RemoveOldMetadata = true + }, + [local.Object, remote.Object]).ConfigureAwait(true); + + Assert.Equal(1, result.Failures); + Assert.Equal("new tagline", item.Tagline); + + // No remote provider answered, so erasing the overview would lose it for good. + Assert.Equal("existing overview", item.Overview); + } + + [Fact] + public async Task RefreshWithProviders_ForeignProviderId_NotStored() + { + var item = new Movie { Name = "Test Movie" }; + + var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Provider"); + provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + var found = new Movie { Name = "Test Movie" }; + found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; + found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375"; + return new MetadataResult<Movie> { HasMetadata = true, Item = found }; + }); + + var service = new TestMetadataService(); + await service.RefreshWithProvidersInternal( + new MetadataResult<Movie> { Item = item }, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true + }, + [provider.Object]).ConfigureAwait(true); + + Assert.False(item.HasProviderId(MetadataProvider.Tmdb)); + Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public async Task RefreshWithProviders_ForeignProviderId_ReplacedInLookupInfo() + { + var item = new Movie { Name = "Test Movie" }; + var lookupInfo = new MovieInfo { Name = item.Name }; + lookupInfo.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; + + var answering = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose); + answering.Setup(p => p.Name).Returns("Answering"); + answering.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + var found = new Movie { Name = "Test Movie" }; + found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "12345"; + return new MetadataResult<Movie> { HasMetadata = true, Item = found }; + }); + + string? tmdbIdSeenBySecondProvider = null; + var following = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose); + following.Setup(p => p.Name).Returns("Following"); + following.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>())) + .ReturnsAsync((MovieInfo info, CancellationToken _) => + { + tmdbIdSeenBySecondProvider = info.GetProviderId(MetadataProvider.Tmdb); + return new MetadataResult<Movie> { HasMetadata = false }; + }); + + var service = new TestMetadataService(); + await service.RefreshWithProvidersInternal( + new MetadataResult<Movie> { Item = item }, + lookupInfo, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true + }, + [answering.Object, following.Object]).ConfigureAwait(true); + + // The stored id cannot be a TMDb one, so the provider that still has to run must get the id + // that was just found instead of failing on the same bad one. + Assert.Equal("12345", tmdbIdSeenBySecondProvider); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RefreshWithProviders_ForeignPersonProviderId_NotStored(bool replaceAllMetadata) + { + var item = new Movie { Name = "Test Movie" }; + var existing = new MetadataResult<Movie> { Item = item }; + existing.AddPerson(new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor }); + + var provider = new Mock<IRemoteMetadataProvider<Movie, MovieInfo>>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Provider"); + provider.Setup(p => p.GetMetadata(It.IsAny<MovieInfo>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(() => + { + var person = new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor }; + person.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; + person.ProviderIds[MetadataProvider.Imdb.ToString()] = "nm0000123"; + + var found = new MetadataResult<Movie> { HasMetadata = true, Item = new Movie { Name = "Test Movie" } }; + found.AddPerson(person); + return found; + }); + + var service = new TestMetadataService(); + await service.RefreshWithProvidersInternal( + existing, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = replaceAllMetadata + }, + [provider.Object]).ConfigureAwait(true); + + var mergedPerson = Assert.Single(existing.People); + Assert.False(mergedPerson.HasProviderId(MetadataProvider.Tmdb)); + Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb)); + } + + [Theory] + [InlineData(MetadataRefreshMode.FullRefresh, true)] + [InlineData(MetadataRefreshMode.Default, false)] + public async Task RefreshMetadata_ProvidersFoundNothing_PersistsRefreshDateOnFullRefresh(MetadataRefreshMode mode, bool expectSaved) + { + var item = new TestItem + { + Id = Guid.NewGuid(), + Name = "Test Item", + PreferredMetadataLanguage = "en", + PreferredMetadataCountryCode = "US", + DateLastRefreshed = DateTime.UtcNow.AddDays(-60), + DateLastSaved = DateTime.UtcNow.AddDays(-60) + }; + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + var stampBefore = item.DateLastRefreshed; + + var provider = new Mock<IRemoteMetadataProvider<TestItem, ItemLookupInfo>>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Provider"); + provider.Setup(p => p.GetMetadata(It.IsAny<ItemLookupInfo>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(new MetadataResult<TestItem> { HasMetadata = false }); + + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + + var providerManager = new Mock<IProviderManager>(MockBehavior.Loose); + providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>())) + .Returns(Array.Empty<IImageProvider>()); + providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(new[] { (IMetadataProvider<TestItem>)provider.Object }); + providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(Array.Empty<IMetadataSaver>()); + + var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose); + itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true); + + var service = new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = mode, + ImageRefreshMode = mode + }, + CancellationToken.None).ConfigureAwait(true); + + // Nothing was found, so on a full refresh the advanced stamp is the only reason to write the row. + Assert.Equal(expectSaved, item.Saved); + + if (expectSaved) + { + Assert.True(item.DateLastRefreshed > stampBefore); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RefreshMetadata_CustomProviderThrew_LeavesRefreshDateAlone(bool providerThrows) + { + var item = new TestItem + { + Id = Guid.NewGuid(), + Name = "Test Item", + PreferredMetadataLanguage = "en", + PreferredMetadataCountryCode = "US", + DateLastRefreshed = DateTime.UtcNow.AddDays(-60), + DateLastSaved = DateTime.UtcNow.AddDays(-60) + }; + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + var stampBefore = item.DateLastRefreshed; + + // Stands in for the probe provider, whose only other change monitor is the file's + // modification time: if a throw is stamped as a completed refresh the item is never revisited. + var provider = new Mock<ICustomMetadataProvider<TestItem>>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Throwing Provider"); + provider.Setup(p => p.FetchAsync(It.IsAny<TestItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>())) + .Returns(providerThrows + ? Task.FromException<ItemUpdateType>(new InvalidOperationException("probe failed")) + : Task.FromResult(ItemUpdateType.None)); + + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + + var providerManager = new Mock<IProviderManager>(MockBehavior.Loose); + providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>())) + .Returns(Array.Empty<IImageProvider>()); + providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(new[] { (IMetadataProvider<TestItem>)provider.Object }); + providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(Array.Empty<IMetadataSaver>()); + + var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose); + itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true); + + var service = new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh + }, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RefreshMetadata_ImageProviderThrew_LeavesRefreshDateAlone(bool providerThrows) + { + var item = NewStampedTestItem(); + var stampBefore = item.DateLastRefreshed; + + var imageProvider = new Mock<IDynamicImageProvider>(MockBehavior.Loose); + imageProvider.Setup(p => p.Name).Returns("Throwing Image Provider"); + imageProvider.Setup(p => p.GetSupportedImages(It.IsAny<BaseItem>())).Returns(new[] { ImageType.Primary }); + imageProvider.Setup(p => p.GetImage(It.IsAny<BaseItem>(), ImageType.Primary, It.IsAny<CancellationToken>())) + .Returns(providerThrows + ? Task.FromException<DynamicImageResponse>(new InvalidOperationException("image fetch failed")) + : Task.FromResult(new DynamicImageResponse { HasImage = false })); + + var providerManager = NewProviderManager(imageProviders: [imageProvider.Object]); + + var service = NewService(providerManager); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh + }, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore); + } + + [Fact] + public async Task RefreshMetadata_LocalImageValidationThrew_LeavesRefreshDateAlone() + { + var item = NewStampedTestItem(); + var stampBefore = item.DateLastRefreshed; + + // A throw here skips the remote image stage altogether, so no image work happened at all. + var localImageProvider = new Mock<ILocalImageProvider>(MockBehavior.Loose); + localImageProvider.Setup(p => p.Name).Returns("Throwing Local Image Provider"); + localImageProvider.Setup(p => p.GetImages(It.IsAny<BaseItem>(), It.IsAny<IDirectoryService>())) + .Throws(new UnauthorizedAccessException("metadata folder is not readable")); + + var providerManager = NewProviderManager(imageProviders: [localImageProvider.Object]); + + var service = NewService(providerManager); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of<IDirectoryService>()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh + }, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal(stampBefore, item.DateLastRefreshed); + } + + private static TestItem NewStampedTestItem() + { + var item = new TestItem + { + Id = Guid.NewGuid(), + Name = "Test Item", + PreferredMetadataLanguage = "en", + PreferredMetadataCountryCode = "US", + DateLastRefreshed = DateTime.UtcNow.AddDays(-60), + DateLastSaved = DateTime.UtcNow.AddDays(-60) + }; + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + return item; + } + + private static Mock<IProviderManager> NewProviderManager( + IMetadataProvider<TestItem>[]? metadataProviders = null, + IImageProvider[]? imageProviders = null) + { + var providerManager = new Mock<IProviderManager>(MockBehavior.Loose); + providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>())) + .Returns(imageProviders ?? Array.Empty<IImageProvider>()); + providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(metadataProviders ?? Array.Empty<IMetadataProvider<TestItem>>()); + providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>())) + .Returns(Array.Empty<IMetadataSaver>()); + return providerManager; + } + + private static TestItemMetadataService NewService(Mock<IProviderManager> providerManager) + { + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions()); + + var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose); + itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true); + + return new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object); + } + + /// <summary> + /// Stands in for a real item so the refresh stays off the shared BaseItem statics, which other + /// test classes in this assembly overwrite while xUnit runs them in parallel. + /// </summary> + internal sealed class TestItem : BaseItem + { + public bool Saved { get; private set; } + + public override bool RequiresRefresh() => false; + + public override bool IsSaveLocalMetadataEnabled() => false; + + public override string CreatePresentationUniqueKey() => Id.ToString("N", CultureInfo.InvariantCulture); + + public override ItemUpdateType OnMetadataChanged() => ItemUpdateType.None; + + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) => false; + + public override Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) + { + Saved = true; + return Task.CompletedTask; + } + } + + private sealed class TestMetadataService : MetadataService<Movie, MovieInfo> + { + public TestMetadataService() + : base( + Mock.Of<IServerConfigurationManager>(), + NullLogger<MetadataService<Movie, MovieInfo>>.Instance, + Mock.Of<IProviderManager>(), + Mock.Of<IFileSystem>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IExternalDataManager>(), + Mock.Of<IItemRepository>()) + { + } + + public Task<RefreshResult> RefreshWithProvidersInternal( + MetadataResult<Movie> metadata, + MovieInfo id, + MetadataRefreshOptions options, + ICollection<IMetadataProvider> providers) + => RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None); + } + + private sealed class TestItemMetadataService : MetadataService<TestItem, ItemLookupInfo> + { + public TestItemMetadataService(ILibraryManager libraryManager, IProviderManager providerManager, IItemRepository itemRepository) + : base( + Mock.Of<IServerConfigurationManager>(), + NullLogger<MetadataService<TestItem, ItemLookupInfo>>.Instance, + providerManager, + Mock.Of<IFileSystem>(), + libraryManager, + Mock.Of<IExternalDataManager>(), + itemRepository) + { + } + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 5749944fcd..d3b82cefcd 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -377,6 +378,116 @@ namespace Jellyfin.Providers.Tests.Manager GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true); } + [Fact] + public async Task QueueRefresh_ManyItemsQueuedFromManyThreads_ProcessesEveryOne() + { + const int ItemCount = 2000; + + var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray(); + var processed = new ConcurrentBag<Guid>(); + var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>())) + .Returns((Guid id) => + { + // Returning null drains the entry without the whole refresh machinery. + processed.Add(id); + if (processed.Count == ItemCount) + { + allProcessed.TrySetResult(); + } + + return null; + }); + + using var providerManager = GetProviderManager(libraryManager: libraryManager.Object); + + await Parallel.ForEachAsync( + queued, + TestContext.Current.CancellationToken, + (id, _) => + { + providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal); + return ValueTask.CompletedTask; + }); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + + try + { + await allProcessed.Task.WaitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + // Fall through so the assertions report what was lost. + } + + Assert.Empty(providerManager.GetRefreshQueue()); + Assert.Equal(queued.Order().ToArray(), processed.Order().ToArray()); + } + + [Fact] + public async Task QueueRefresh_RefreshCancelsForItsOwnReasons_KeepsDrainingTheQueue() + { + // A provider timeout arrives as an OperationCanceledException, indistinguishable from + // a shutdown; treating it as one would strand the rest of the queue. + const int ItemCount = 200; + + var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray(); + var processed = new ConcurrentBag<Guid>(); + var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var allQueued = new ManualResetEventSlim(false); + var cancelledOnce = false; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>())) + .Returns((Guid id) => + { + if (!cancelledOnce) + { + cancelledOnce = true; + + // Hold the first entry until the whole batch is queued. + allQueued.Wait(TimeSpan.FromSeconds(30)); + throw new OperationCanceledException("provider timed out"); + } + + processed.Add(id); + if (processed.Count == ItemCount - 1) + { + allProcessed.TrySetResult(); + } + + return null; + }); + + using var providerManager = GetProviderManager(libraryManager: libraryManager.Object); + + foreach (var id in queued) + { + providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal); + } + + allQueued.Set(); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + + try + { + await allProcessed.Task.WaitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + // Fall through so the assertions report what was stranded. + } + + Assert.Empty(providerManager.GetRefreshQueue()); + Assert.Equal(ItemCount - 1, processed.Count); + } + private static void GetMetadataProviders_CanRefreshMetadata_Tester( string providerType, bool expected, @@ -554,15 +665,22 @@ namespace Jellyfin.Providers.Tests.Manager private static ProviderManager GetProviderManager( ServerConfiguration? serverConfiguration = null, LibraryOptions? libraryOptions = null, - IBaseItemManager? baseItemManager = null) + IBaseItemManager? baseItemManager = null, + ILibraryManager? libraryManager = null) { var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict); serverConfigurationManager.Setup(i => i.Configuration) .Returns(serverConfiguration ?? new ServerConfiguration()); - var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict); - libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>())) - .Returns(libraryOptions ?? new LibraryOptions()); + if (libraryManager is null) + { + var libraryManagerMock = new Mock<ILibraryManager>(MockBehavior.Strict); + libraryManagerMock.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>())) + .Returns(libraryOptions ?? new LibraryOptions()); + libraryManagerMock.Setup(i => i.GetCollectionFolders(It.IsAny<BaseItem>())) + .Returns(new List<Folder>()); + libraryManager = libraryManagerMock.Object; + } var providerManager = new ProviderManager( Mock.Of<IHttpClientFactory>(), @@ -572,7 +690,7 @@ namespace Jellyfin.Providers.Tests.Manager _logger, Mock.Of<IFileSystem>(), Mock.Of<IServerApplicationPaths>(), - libraryManager.Object, + libraryManager, baseItemManager!, Mock.Of<ILyricManager>(), Mock.Of<IMemoryCache>(), diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs index 2438ef06d1..59d3f42edf 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs @@ -3,7 +3,9 @@ using AutoFixture; using AutoFixture.AutoMoq; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; using MediaBrowser.Providers.MediaInfo; using Moq; using Xunit; @@ -75,4 +77,62 @@ public class FFProbeVideoInfoTests Assert.All(chapters, chapter => Assert.True(chapter.StartPositionTicks < runtime)); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FetchEmbeddedInfo_NoExtra_AppliesContainerDates(bool replaceAllMetadata) + { + var video = new Video(); + + _fFProbeVideoInfo.FetchEmbeddedInfo(video, CreateMediaInfoWithDates(), CreateRefreshOptions(replaceAllMetadata), new LibraryOptions()); + + Assert.Equal(2016, video.ProductionYear); + Assert.Equal(new DateTime(2016, 5, 4, 0, 0, 0, DateTimeKind.Utc), video.PremiereDate); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FetchEmbeddedInfo_Extra_IgnoresContainerDates(bool replaceAllMetadata) + { + var video = new Video + { + ExtraType = ExtraType.Trailer, + ProductionYear = 1982, + PremiereDate = new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc) + }; + + _fFProbeVideoInfo.FetchEmbeddedInfo(video, CreateMediaInfoWithDates(), CreateRefreshOptions(replaceAllMetadata), new LibraryOptions()); + + Assert.Equal(1982, video.ProductionYear); + Assert.Equal(new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc), video.PremiereDate); + } + + [Fact] + public void FetchEmbeddedInfo_ExtraWithoutDates_StaysWithoutDates() + { + var video = new Video + { + ExtraType = ExtraType.Trailer + }; + + _fFProbeVideoInfo.FetchEmbeddedInfo(video, CreateMediaInfoWithDates(), CreateRefreshOptions(false), new LibraryOptions()); + + Assert.Null(video.ProductionYear); + Assert.Null(video.PremiereDate); + } + + private static MediaBrowser.Model.MediaInfo.MediaInfo CreateMediaInfoWithDates() + => new() + { + ProductionYear = 2016, + PremiereDate = new DateTime(2016, 5, 4, 0, 0, 0, DateTimeKind.Utc) + }; + + private static MetadataRefreshOptions CreateRefreshOptions(bool replaceAllMetadata) + => new(Mock.Of<IDirectoryService>()) + { + ReplaceAllMetadata = replaceAllMetadata + }; } diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 876f18741f..ce451861ef 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -179,6 +179,146 @@ public class MediaInfoResolverTests Assert.Empty(streams); } + [Fact] + public void GetExternalFiles_VobSubIdxAndSubPair_OnlyReturnsIdxFile() + { + // VobSub (.sub) payloads only carry per-track language metadata when read + // alongside their paired .idx index file. When both are present, only the + // .idx file should be returned so it (not the raw .sub) gets probed. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxWithoutMatchingSub_DoesNotReturnIdxFile() + { + // An .idx file with no paired .sub cannot be probed for anything, so it must be + // left out entirely rather than surfaced as a doomed-to-fail probe candidate. + // Surfacing it anyway would also make it "exist" from Jellyfin's perspective + // even after the real .sub is deleted, preventing stale subtitle stream data + // from ever being cleared on a rescan. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = GetDirectoryServiceForExternalFile("My.Video.idx"); + var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); + + Assert.Empty(streams); + } + + [Fact] + public void GetExternalFiles_StandaloneSubWithoutIdx_StillReturnsSubFile() + { + // Guards against the .idx/.sub pairing suppression firing when there is no + // .idx sidecar at all - a lone .sub file must still be returned. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = GetDirectoryServiceForExternalFile("My.Video.sub"); + var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxAndSubInDifferentDirectories_DoesNotPair() + { + // A same-named .idx and .sub split across the video folder and the internal + // metadata folder cannot be paired by ffprobe (it only looks next to the .idx), + // so the .sub must still be returned, but the orphaned .idx (no sibling .sub in + // its own directory) must be left out since it cannot be probed. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { MetadataDirectoryPath + "/My.Video.idx" }); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxAndSubWithMatchingLanguageFlag_SuppressesSub() + { + // A .idx/.sub pair sharing the same filename flags (e.g. a language token) should + // still pair and suppress the .sub, just like an unflagged pair. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.en.idx", VideoDirectoryPath + "/My.Video.en.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetExternalFiles_VobSubIdxAndSubWithMismatchedNames_DoesNotPair() + { + // An .idx and .sub with different basenames (e.g. differing filename flags) are not + // a pair ffprobe would resolve. The .sub must still be returned, but the orphaned + // .idx (no same-named sibling .sub) must be left out since it cannot be probed. + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + var video = new Movie + { + Path = VideoDirectoryPath + "/My.Video.mkv" + }; + + var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) + .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.en.sub" }); + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) + .Returns(Array.Empty<string>()); + + var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList(); + + var stream = Assert.Single(streams); + Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase); + } + [Theory] [InlineData("https://url.com/My.Video.mkv")] [InlineData(VideoDirectoryPath)] // valid but no files found for this test diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs new file mode 100644 index 0000000000..cd7ae0f525 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs @@ -0,0 +1,113 @@ +using Emby.Naming.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Providers.MediaInfo; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo; + +public class ProbeProviderTests +{ + private readonly ProbeProvider _probeProvider; + + public ProbeProviderTests() + { + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsAny<string>())) + .Returns(MediaProtocol.File); + + // prep BaseItem and Video for calls made that expect managers + BaseItem.MediaSourceManager = mediaSourceManager.Object; + Video.RecordingsManager = Mock.Of<IRecordingsManager>(); + + _probeProvider = new ProbeProvider( + mediaSourceManager.Object, + Mock.Of<IMediaEncoder>(), + Mock.Of<IBlurayExaminer>(), + Mock.Of<ILocalizationManager>(), + Mock.Of<IChapterManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ISubtitleManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IFileSystem>(), + NullLoggerFactory.Instance, + new NamingOptions(), + Mock.Of<ILyricManager>(), + Mock.Of<IMediaAttachmentRepository>(), + Mock.Of<IMediaStreamRepository>()); + } + + [Fact] + public void HasChanged_NeverProbedVideo_ReturnsTrue() + { + // A probe that threw leaves the item like this while the refresh is stamped as done, and the + // file's modification time never changes afterwards, so nothing else would ask for a retry. + var item = new Episode { Path = "/media/show/S01E01.mkv" }; + + Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_NeverProbedAudio_ReturnsTrue() + { + var item = new Audio { Path = "/media/music/track.flac" }; + + Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Theory] + [InlineData(12345L, null)] + [InlineData(null, 2480000)] + [InlineData(12345L, 2480000)] + public void HasChanged_ProbedVideo_ReturnsFalse(long? runTimeTicks, int? totalBitrate) + { + var item = new Episode + { + Path = "/media/show/S01E01.mkv", + RunTimeTicks = runTimeTicks, + TotalBitrate = totalBitrate + }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_VirtualItemWithoutMediaInfo_ReturnsFalse() + { + var item = new Episode { Path = "/media/show/S01E01.mkv", IsVirtualItem = true }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_PlaceHolderWithoutMediaInfo_ReturnsFalse() + { + var item = new Episode { Path = "/media/show/S01E01.disc", IsPlaceHolder = true }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } + + [Fact] + public void HasChanged_ShortcutWithoutMediaInfo_ReturnsFalse() + { + // A .strm is only probed when remote content probing is enabled, so an empty one is expected. + var item = new Episode { Path = "/media/show/S01E01.strm", IsShortcut = true }; + + Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>())); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs b/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs new file mode 100644 index 0000000000..c5ec0de02c --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs @@ -0,0 +1,59 @@ +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Music; +using Xunit; + +namespace Jellyfin.Providers.Tests.Music; + +public static class AlbumInfoExtensionsTests +{ + private const string ExampleMbid = "59b5a40b-e2fd-3f18-a218-e8c9aae12ab5"; + private const string SongMbid = "6c301dbd-6ccb-3403-a6c4-6a22240a0297"; + + [Theory] + [InlineData(ExampleMbid, ExampleMbid)] + // Another provider's id under a MusicBrainz key reads as no id, so the caller searches instead of + // handing a value the MusicBrainz client throws on. + [InlineData("111239", null)] + [InlineData("", null)] + public static void GetReleaseId_OnlyReturnsMbids(string id, string? expected) + { + var info = new AlbumInfo(); + info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = id; + + Assert.Equal(expected, info.GetReleaseId()); + } + + [Fact] + public static void GetReleaseId_ForeignId_FallsBackToSongs() + { + var song = new SongInfo(); + song.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = SongMbid; + + var info = new AlbumInfo { SongInfos = [song] }; + info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = "111239"; + + Assert.Equal(SongMbid, info.GetReleaseId()); + } + + [Fact] + public static void GetMusicBrainzArtistId_ForeignId_FallsBackToArtistIds() + { + var info = new AlbumInfo(); + info.ProviderIds[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = "111239"; + info.ArtistProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = ExampleMbid; + + Assert.Equal(ExampleMbid, info.GetMusicBrainzArtistId()); + } + + [Theory] + [InlineData(ExampleMbid, ExampleMbid)] + [InlineData("111239", null)] + public static void GetMusicBrainzArtistId_ArtistInfo_OnlyReturnsMbids(string id, string? expected) + { + var info = new ArtistInfo(); + info.ProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = id; + + Assert.Equal(expected, info.GetMusicBrainzArtistId()); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs new file mode 100644 index 0000000000..bd50a903f1 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs @@ -0,0 +1,81 @@ +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Providers.Plugins.Omdb; +using Xunit; + +namespace Jellyfin.Providers.Tests.Omdb +{ + public class OmdbProviderTests + { + [Fact] + public void AddPeople_CommaSeparatedList_SplitsIntoIndividualPeople() + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, "Philip G. Epstein, Julius J. Epstein, Howard Koch", PersonKind.Writer); + + Assert.Equal( + new[] { "Philip G. Epstein", "Julius J. Epstein", "Howard Koch" }, + result.People!.Select(p => p.Name)); + Assert.All(result.People!, p => Assert.Equal(PersonKind.Writer, p.Type)); + } + + [Fact] + public void AddPeople_RoleAnnotations_AreStrippedAndDeduplicated() + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, "Mari Okada (screenplay), Mari Okada (story), Jun'ichi Satô (screenplay), Jun'ichi Satô (story)", PersonKind.Writer); + + Assert.Equal( + new[] { "Mari Okada", "Jun'ichi Satô" }, + result.People!.Select(p => p.Name)); + } + + [Theory] + [InlineData( + "Jerry Siegel (created by: Superman, Superboy), Bob Kane (created by: Batman)", + "Jerry Siegel|Bob Kane")] + [InlineData("Alan Moore (created by: John Constantine)", "Alan Moore")] + public void AddPeople_CommaInsideAnAnnotation_StaysOneCredit(string credits, string expected) + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, credits, PersonKind.Writer); + + Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name)); + } + + [Theory] + [InlineData("Jack Salvatore, Jr.", "Jack Salvatore, Jr.")] + [InlineData("Efrem Zimbalist, Jr., Tom Hanks", "Efrem Zimbalist, Jr.|Tom Hanks")] + [InlineData("Tom Hanks, Sammy Davis, Jr", "Tom Hanks|Sammy Davis, Jr")] + [InlineData("Harold Ramis, Ken Griffey, III (voice)", "Harold Ramis|Ken Griffey, III")] + [InlineData("Robert Downey Jr., Gwyneth Paltrow", "Robert Downey Jr.|Gwyneth Paltrow")] + [InlineData("Jr., Tom Hanks", "Jr.|Tom Hanks")] + public void AddPeople_GenerationalSuffix_StaysWithItsName(string credits, string expected) + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, credits, PersonKind.Actor); + + Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("(uncredited)")] + public void AddPeople_NoUsableName_AddsNothing(string? credits) + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, credits!, PersonKind.Actor); + + Assert.Null(result.People); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs new file mode 100644 index 0000000000..a73ed61a75 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Playlists; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Playlists; + +public sealed class PlaylistItemsProviderTests : IDisposable +{ + private const string AccentedFolder = "Música épica"; + private const string AccentedSong = "Canción.mp3"; + private const string AsciiSong = "Song.mp3"; + + private readonly string _libraryRoot; + private readonly PlaylistItemsProvider _sut; + + public PlaylistItemsProviderTests() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + _libraryRoot = Path.Combine(Path.GetTempPath(), "jellyfin-playlist-tests", Guid.NewGuid().ToString("N")); + var mediaFolder = Path.Combine(_libraryRoot, AccentedFolder); + Directory.CreateDirectory(mediaFolder); + File.WriteAllText(Path.Combine(mediaFolder, AccentedSong), string.Empty); + File.WriteAllText(Path.Combine(mediaFolder, AsciiSong), string.Empty); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(m => m.FindByPath(It.IsAny<string>(), It.IsAny<bool?>())) + .Returns((string path, bool? _) => new Audio { Id = Guid.NewGuid(), Path = path }); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(m => m.MakeAbsolutePath(It.IsAny<string>(), It.IsAny<string>())) + .Returns((string folderPath, string filePath) => Path.GetFullPath(Path.Combine(folderPath, filePath))); + + _sut = new PlaylistItemsProvider(NullLogger<PlaylistItemsProvider>.Instance, libraryManager.Object, fileSystem.Object); + } + + [Fact] + public void GetM3uItems_Utf8Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// Playlists written by Windows media players default to the local codepage rather than UTF-8. + /// Decoding those as UTF-8 mangles every accented character and loses the entry. + /// </summary> + [Fact] + public void GetM3uItems_LegacyCodepageEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.GetEncoding(1252), [$"{AccentedFolder}/{AccentedSong}"], 1); + + /// <summary> + /// The files on disk are stored precomposed (NFC), the playlist references them decomposed (NFD). + /// </summary> + [Fact] + public void GetM3uItems_DecomposedEntries_ResolvesAccentedPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}".Normalize(NormalizationForm.FormD)], 1); + + [Fact] + public void GetM3uItems_AsciiEntries_ResolvesPaths() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AsciiSong}"], 1); + + [Fact] + public void GetM3uItems_Utf16Entries_ResolvesAccentedPaths() + => AssertResolved(Encoding.Unicode, [$"{AccentedFolder}/{AccentedSong}"], 1); + + [Fact] + public void GetM3uItems_MixedEntries_ResolvesEveryEntry() + => AssertResolved( + Encoding.GetEncoding(1252), + [$"{AccentedFolder}/{AccentedSong}", $"{AccentedFolder}/{AsciiSong}"], + 2); + + [Fact] + public void GetM3uItems_MissingFile_ResolvesNothing() + => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/Does not exist.mp3"], 0); + + public void Dispose() + { + if (Directory.Exists(_libraryRoot)) + { + Directory.Delete(_libraryRoot, true); + } + } + + private void AssertResolved(Encoding encoding, string[] entries, int expected) + { + var playlistPath = Path.Combine(_libraryRoot, "playlist.m3u"); + var content = new StringBuilder("#EXTM3U\n"); + foreach (var entry in entries) + { + content.Append("#EXTINF:1,Title\n").Append(entry).Append('\n'); + } + + File.WriteAllBytes( + playlistPath, + [.. encoding.GetPreamble(), .. encoding.GetBytes(content.ToString())]); + + using var stream = File.OpenRead(playlistPath); + var resolved = _sut.GetM3uItems(stream, playlistPath, [_libraryRoot]).ToList(); + + Assert.Equal(expected, resolved.Count); + } +} diff --git a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs index 8f5b1b3c48..ea762256db 100644 --- a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs +++ b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs @@ -1,5 +1,6 @@ using System; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; @@ -15,9 +16,23 @@ using Xunit; namespace Jellyfin.Providers.Tests.TV; -public class EpisodeMetadataServiceTests +// put tests that mock the static LibraryManager in the same collection to avoid test interference +[Collection("LibraryManagerTests")] +public sealed class EpisodeMetadataServiceTests : IDisposable { private readonly TestEpisodeMetadataService _service = new(); + private readonly ILibraryManager? _previousLibraryManager; + + public EpisodeMetadataServiceTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + BaseItem.LibraryManager = Mock.Of<ILibraryManager>(); + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager; + } [Fact] public void MergeData_ProviderSeasonOverridesPathDerivedSeason() @@ -88,6 +103,59 @@ public class EpisodeMetadataServiceTests Assert.Equal(1, target.Item.ParentIndexNumber); } + [Theory] + [InlineData(2, 1)] + [InlineData(22, 21)] + [InlineData(21, 2)] // e.g. "Series - S03E21 - E2 (1080p BluRay x265).mkv", where "E2" is the episode title + public void BeforeSave_ReversedEpisodeRange_ClearsIndexNumberEnd(int indexNumber, int indexNumberEnd) + { + var item = new Episode + { + IndexNumber = indexNumber, + IndexNumberEnd = indexNumberEnd + }; + + var updateType = _service.BeforeSave(item); + + // The episode number identifies the item, so it is kept and the impossible range is dropped + Assert.Equal(indexNumber, item.IndexNumber); + Assert.Null(item.IndexNumberEnd); + Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport)); + } + + [Fact] + public void BeforeSave_EpisodeRangeWithoutStart_ClearsIndexNumberEnd() + { + var item = new Episode + { + IndexNumber = null, + IndexNumberEnd = 2 + }; + + var updateType = _service.BeforeSave(item); + + Assert.Null(item.IndexNumberEnd); + Assert.Null(item.IndexNumber); + Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport)); + } + + [Theory] + [InlineData(1, 2)] // Regular multi episode file + [InlineData(1, 1)] // Degenerate but not contradictory + public void BeforeSave_ValidEpisodeRange_KeepsIndexNumberEnd(int indexNumber, int indexNumberEnd) + { + var item = new Episode + { + IndexNumber = indexNumber, + IndexNumberEnd = indexNumberEnd + }; + + _service.BeforeSave(item); + + Assert.Equal(indexNumber, item.IndexNumber); + Assert.Equal(indexNumberEnd, item.IndexNumberEnd); + } + private sealed class TestEpisodeMetadataService : EpisodeMetadataService { public TestEpisodeMetadataService() @@ -106,5 +174,10 @@ public class EpisodeMetadataServiceTests { MergeData(source, target, Array.Empty<MetadataField>(), replaceData, mergeMetadataSettings); } + + public ItemUpdateType BeforeSave(Episode item) + { + return BeforeSaveInternal(item, false, ItemUpdateType.None); + } } } diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs new file mode 100644 index 0000000000..7813013c05 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs @@ -0,0 +1,275 @@ +using System; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb.TV; +using TMDbLib.Objects.Search; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb; + +public class TmdbMissingEpisodeProviderTests +{ + private static readonly DateTime _today = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc); + + [Theory] + // No air date -> never imported, regardless of options. + [InlineData(null, true, true, false, false, false)] + [InlineData(null, false, false, false, false, false)] + // Future (unaired) episodes are gated by the unaired option. + [InlineData(5, true, false, false, false, true)] + [InlineData(5, false, false, false, false, false)] + [InlineData(5, false, true, false, false, false)] + // Today counts as unaired. + [InlineData(0, true, false, false, false, true)] + [InlineData(0, false, false, false, false, false)] + // Past (already aired) episodes are gated by the missing option. + [InlineData(-5, false, true, false, false, true)] + [InlineData(-5, false, false, false, false, false)] + [InlineData(-5, true, false, false, false, false)] + // Specials are never imported when the specials option is off, regardless of air date. + [InlineData(5, true, false, true, false, false)] + [InlineData(-5, false, true, true, false, false)] + // Specials follow the normal air-date gating when the specials option is on. + [InlineData(5, true, false, true, true, true)] + [InlineData(5, false, false, true, true, false)] + [InlineData(-5, false, true, true, true, true)] + [InlineData(-5, false, false, true, true, false)] + public void ShouldImportEpisode_RespectsAirDateAndOptions(int? dayOffset, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials, bool expected) + { + DateTime? premiere = dayOffset.HasValue ? _today.AddDays(dayOffset.Value) : null; + + Assert.Equal(expected, TmdbMissingEpisodeProvider.ShouldImportEpisode(premiere, _today, importUnaired, importMissing, isSpecial, importSpecials)); + } + + [Fact] + public void ShouldPrune_AgedOutVirtualTmdbEpisode_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_NotInPruningMode_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_StillUpcoming_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_VirtualEpisodeFromAnotherProvider_ReturnsFalse() + { + // No TMDb id -> not created by this provider (e.g. a TheTVDB plugin entry) -> left untouched. + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: false); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_PhysicalEpisode_ReturnsFalse() + { + var episode = new Episode { Path = "/media/show/Season 01/s01e01.mkv", PremiereDate = _today.AddDays(-1) }; + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredWithinGracePeriod_ReturnsFalse() + { + // Aired two days ago but the grace period keeps it around for the file to be added. + var episode = VirtualEpisode(_today.AddDays(-2), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredBeyondGracePeriod_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-10), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsDisabled_ReturnsTrue() + { + // Specials are removed entirely when the specials option is off, even when not in pruning mode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 7, importSpecials: false)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsEnabled_FollowsNormalRules() + { + // With specials enabled, an upcoming special is kept like any other upcoming episode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void GetPremiereDate_NullAirDate_ReturnsNull() + { + Assert.Null(TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = null })); + } + + [Fact] + public void GetPremiereDate_AirDate_ReturnsUtc() + { + var airDate = new DateTime(2026, 7, 28); + + var result = TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = airDate }); + + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result!.Value.Kind); + Assert.Equal(DateTime.SpecifyKind(airDate, DateTimeKind.Local).ToUniversalTime(), result.Value); + } + + [Fact] + public void UpdateVirtualEpisode_PlaceholderTitleReplaced_UpdatesAndReturnsTrue() + { + var episode = new Episode { Name = "Episode 14" }; + var tmdbEpisode = new TvSeasonEpisode { Name = "The Real Title" }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("The Real Title", episode.Name); + } + + [Fact] + public void UpdateVirtualEpisode_NoChanges_ReturnsFalse() + { + var date = _today; + var episode = new Episode { Name = "Same", Overview = "Description", PremiereDate = date }; + var tmdbEpisode = new TvSeasonEpisode { Name = "Same", Overview = "Description" }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, date)); + } + + [Fact] + public void UpdateVirtualEpisode_EmptyTmdbValues_DoNotOverwrite() + { + var episode = new Episode { Name = "Existing", Overview = "Existing overview" }; + var tmdbEpisode = new TvSeasonEpisode { Name = string.Empty, Overview = null }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("Existing", episode.Name); + Assert.Equal("Existing overview", episode.Overview); + } + + [Fact] + public void UpdateVirtualEpisode_RescheduledAirDate_UpdatesPremiereAndYear() + { + var episode = new Episode { Name = "X", PremiereDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }; + var newAirDate = new DateTime(2026, 8, 15); + var newPremiere = DateTime.SpecifyKind(newAirDate, DateTimeKind.Local).ToUniversalTime(); + var tmdbEpisode = new TvSeasonEpisode { Name = "X", AirDate = newAirDate }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, newPremiere)); + Assert.Equal(newPremiere, episode.PremiereDate); + Assert.Equal(2026, episode.ProductionYear); + } + + [Fact] + public void BuildSeasonSortNameTemplate_NoNameSortedPhysicalSeason_ReturnsNull() + { + // No physical season carries a forced (name-based) sort name -> virtual seasons keep their + // bare-index sort, so no template is produced. + var seasons = new[] + { + PhysicalSeason(1, forcedSortName: null), + VirtualSeason(3), + }; + + Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(seasons)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_MirrorsSiblingConventionAndSwapsNumber() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Season 01"), + VirtualSeason(3), + }); + + Assert.NotNull(template); + // Keeps the sibling's text token and zero-padding width, swapping in the target number. + Assert.Equal("Season 03", template!(3)); + Assert.Equal("Season 12", template(12)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_PreservesNonEnglishToken() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Staffel 1"), + VirtualSeason(2), + }); + + Assert.NotNull(template); + Assert.Equal("Staffel 2", template!(2)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_SiblingWithoutDigits_ReturnsNull() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Miniseries"), + VirtualSeason(2), + }); + + Assert.Null(template); + } + + [Fact] + public void BuildSeasonSortNameTemplate_IgnoresVirtualSeasonsAsReference() + { + // A virtual season's own forced sort name must not be used as the convention source. + var virtualWithForced = VirtualSeason(3); + virtualWithForced.ForcedSortName = "Season 03"; + + Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: null), + virtualWithForced, + })); + } + + private static Season PhysicalSeason(int indexNumber, string? forcedSortName) + { + var season = new Season { IndexNumber = indexNumber, Path = $"/media/show/Season {indexNumber:00}" }; + if (!string.IsNullOrEmpty(forcedSortName)) + { + season.ForcedSortName = forcedSortName; + } + + return season; + } + + private static Season VirtualSeason(int indexNumber) + => new Season { IndexNumber = indexNumber, IsVirtualItem = true }; + + private static Episode VirtualEpisode(DateTime premiereDate, bool withTmdbId, int? seasonNumber = null) + { + var episode = new Episode { PremiereDate = premiereDate, IsVirtualItem = true, ParentIndexNumber = seasonNumber }; + if (withTmdbId) + { + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + } + + return episode; + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs new file mode 100644 index 0000000000..182e7c52eb --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb; +using TMDbLib.Objects.TvShows; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb +{ + public class TmdbUtilsCastTests + { + private static readonly PluginConfiguration _config = new() { MaxCastMembers = 10 }; + + [Fact] + public void MapAggregateCast_MemberWithSeveralRoles_YieldsOneCreditPerRole() + { + var cast = new List<CastAggregate> + { + CreateAggregate("Megumi Toyoguchi", 1, 0, ("Tabby (voice)", 3), ("Mimiru (voice)", 12)) + }; + + var people = TmdbUtils.MapAggregateCast(cast, _config, _ => null).ToArray(); + + // The character they played the longest comes first, which is their own billing. + Assert.Equal(["Mimiru (voice)", "Tabby (voice)"], people.Select(p => p.Role)); + Assert.All(people, p => Assert.Equal("Megumi Toyoguchi", p.Name)); + Assert.All(people, p => Assert.Equal(PersonKind.Actor, p.Type)); + Assert.All(people, p => Assert.Equal("1", p.GetProviderId(MetadataProvider.Tmdb))); + } + + [Fact] + public void MapAggregateCast_MemberWithoutARole_IsStillCredited() + { + var cast = new List<CastAggregate> { CreateAggregate("Uncredited Actor", 2, 0) }; + + var person = Assert.Single(TmdbUtils.MapAggregateCast(cast, _config, _ => null)); + + Assert.Equal(string.Empty, person.Role); + } + + [Fact] + public void MapAggregateCast_MoreThanConfigured_KeepsTheTopBilled() + { + var cast = Enumerable.Range(0, 5) + .Select(i => CreateAggregate($"Actor {4 - i}", i + 1, 4 - i, ($"Role {4 - i}", 1))) + .ToList(); + + var people = TmdbUtils.MapAggregateCast(cast, new PluginConfiguration { MaxCastMembers = 2 }, _ => null); + + Assert.Equal(["Actor 0", "Actor 1"], people.Select(p => p.Name)); + } + + [Fact] + public void MapAggregateCast_HideMissingCastMembers_DropsTheOnesWithoutAProfile() + { + var withProfile = CreateAggregate("Has Profile", 1, 0, ("Hero", 1)); + withProfile.ProfilePath = "/profile.jpg"; + var cast = new List<CastAggregate> { withProfile, CreateAggregate("No Profile", 2, 1, ("Villain", 1)) }; + + var people = TmdbUtils.MapAggregateCast( + cast, + new PluginConfiguration { MaxCastMembers = 10, HideMissingCastMembers = true }, + _ => null); + + Assert.Equal(["Has Profile"], people.Select(p => p.Name)); + } + + [Fact] + public void MapCast_FlatCredits_YieldOneCreditEach() + { + var cast = new List<Cast> + { + new() { Name = "Kevin Conroy", Id = 1, Order = 0, Character = " Batman (voice) " }, + new() { Name = " ", Id = 2, Order = 1, Character = "Nobody" } + }; + + var person = Assert.Single(TmdbUtils.MapCast(cast, _config, _ => null)); + + Assert.Equal("Kevin Conroy", person.Name); + Assert.Equal("Batman (voice)", person.Role); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MapCast_NoCast_YieldsNothing(bool aggregate) + { + Assert.Empty(aggregate + ? TmdbUtils.MapAggregateCast(null, _config, _ => null) + : TmdbUtils.MapCast(null, _config, _ => null)); + } + + private static CastAggregate CreateAggregate(string name, int id, int order, params (string Character, int Episodes)[] roles) + { + return new CastAggregate + { + Name = name, + Id = id, + Order = order, + Roles = roles.Select(role => new CastRole { Character = role.Character, EpisodeCount = role.Episodes }).ToList() + }; + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index fb0a08c29c..03bad3555e 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -1,4 +1,9 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; using MediaBrowser.Providers.Plugins.Tmdb; +using TMDbLib.Objects.Search; using Xunit; namespace Jellyfin.Providers.Tests.Tmdb @@ -34,5 +39,217 @@ namespace Jellyfin.Providers.Tests.Tmdb { Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage)); } + + [Theory] + [InlineData("11", true, 11)] + // An id another provider filed under the TMDb key must not throw, it is simply not a TMDb id. + [InlineData("nm0000123", false, 0)] + [InlineData("tt0113375", false, 0)] + [InlineData("11.0", false, 0)] + [InlineData("-11", false, 0)] + [InlineData("0", false, 0)] + [InlineData("", false, 0)] + [InlineData(null, false, 0)] + public static void TryParseTmdbId_OnlyAcceptsTmdbIds(string? value, bool expected, int expectedId) + { + Assert.Equal(expected, TmdbUtils.TryParseTmdbId(value, out var tmdbId)); + Assert.Equal(expectedId, tmdbId); + } + + [Theory] + [InlineData("11", true, 11)] + [InlineData("nm0000123", false, 0)] + public static void TryGetTmdbId_OnlyAcceptsTmdbIds(string value, bool expected, int expectedId) + { + var item = new Movie(); + item.ProviderIds[MetadataProvider.Tmdb.ToString()] = value; + + Assert.Equal(expected, item.TryGetTmdbId(out var tmdbId)); + Assert.Equal(expectedId, tmdbId); + } + + [Fact] + public static void TryGetTmdbId_NoId_False() + { + Assert.False(new Movie().TryGetTmdbId(out var tmdbId)); + Assert.Equal(0, tmdbId); + } + + [Theory] + [InlineData("The Amityville Horror", "The Amityville Horror")] + [InlineData("WALL-E", "WALL E")] + // The interpunct is kept, it matches the TMDb title better than a space does. + [InlineData("WALL·E", "WALL·E")] + [InlineData("50-50", "50 50")] + [InlineData("A Christmas No. 1", "A Christmas No 1")] + // Vulgar fractions are numbers, dropping them turned "8½" into a search for "8". + [InlineData("8½", "8½")] + [InlineData("9½ Weeks", "9½ Weeks")] + [InlineData(" Léon: The Professional ", "Léon The Professional")] + public static void CleanName_Valid_Success(string name, string expected) + { + Assert.Equal(expected, TmdbUtils.CleanName(name)); + } + + [Theory] + [InlineData("WALL-E", "wall e")] + [InlineData("WALL·E", "wall e")] + [InlineData("WALL E", "wall e")] + [InlineData("8½", "8½")] + [InlineData("Ocean's Eleven", "ocean s eleven")] + [InlineData(null, "")] + [InlineData(" ", "")] + public static void NormalizeTitle_Valid_Success(string? title, string expected) + { + Assert.Equal(expected, TmdbUtils.NormalizeTitle(title)); + } + + [Theory] + // An unconfigured size fetches the original image, so it keeps the original resolution. + [InlineData(null, true)] + [InlineData("", true)] + [InlineData("original", true)] + [InlineData("Original", true)] + [InlineData("w500", false)] + [InlineData("original2", false)] + public static void IsOriginalImageSize_Valid_Success(string? size, bool expected) + { + Assert.Equal(expected, TmdbUtils.IsOriginalImageSize(size)); + } + + [Theory] + [MemberData(nameof(FindBestMatch_Movies_TestData))] + public static void FindBestMatch_Movies_PicksExpected(string description, string name, int year, IReadOnlyList<SearchMovie> results, int expectedId) + { + var match = TmdbUtils.FindBestMatch(results, name, year); + + Assert.NotNull(match); + Assert.True(expectedId == match.Id, $"{description}: expected {expectedId} but matched {match.Id}"); + } + + [Fact] + public static void FindBestMatch_Series_PicksMatchingFirstAirYear() + { + IReadOnlyList<SearchTv> results = + [ + Series(10042, "Doc", "Doc", 2001), + Series(101048, "Doc", "Doc", 2020), + Series(255055, "Doc", "Doc", 2025), + Series(2430, "Doc Martin", "Doc Martin", 2004) + ]; + + var match = TmdbUtils.FindBestMatch(results, "Doc", 2025); + + Assert.NotNull(match); + Assert.Equal(255055, match.Id); + } + + [Fact] + public static void FindBestMatch_NoResults_ReturnsNull() + { + Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchMovie>(), "Mulan", 2020)); + Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchTv>(), "Doc", 2025)); + Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchMovie>?)null, "Mulan", 2020)); + Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchTv>?)null, "Doc", 2025)); + } + + public static TheoryData<string, string, int, IReadOnlyList<SearchMovie>, int> FindBestMatch_Movies_TestData() + => new() + { + // TMDb's year parameter does not filter, so the remake and the original both come back and + // the wrong one is first. Results are in the order the live API returned them. + { + "Mulan (2020)", "Mulan", 2020, + [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)], + 337401 + }, + { + "Mulan (1998)", "Mulan", 1998, + [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)], + 10674 + }, + { + "Aladdin (2019)", "Aladdin", 2019, + [Movie(812, "Aladdin", "Aladdin", 1992), Movie(420817, "Aladdin", "Aladdin", 2019), Movie(602411, "Adventures of Aladdin", "Adventures of Aladdin", 2019)], + 420817 + }, + { + "The Lion King (2019)", "The Lion King", 2019, + [Movie(8587, "The Lion King", "The Lion King", 1994), Movie(420818, "The Lion King", "The Lion King", 2019)], + 420818 + }, + { + "The Amityville Horror (1979)", "The Amityville Horror", 1979, + [Movie(10065, "The Amityville Horror", "The Amityville Horror", 2005), Movie(11449, "The Amityville Horror", "The Amityville Horror", 1979)], + 11449 + }, + // A featurette outranks the film it belongs to. The interpunct must not stop "WALL-E" from + // matching "WALL·E", or the prefix match on the featurette wins. + { + "WALL-E (2008)", "WALL-E", 2008, + [Movie(877268, "WALL·E's Treasures & Trinkets", "WALL·E's Treasures & Trinkets", 2008), Movie(10681, "WALL·E", "WALL·E", 2008), Movie(10673, "Wall Street", "Wall Street", 1987)], + 10681 + }, + // The name only survives as "8" if the fraction is stripped, and then every 1963 result ties. + { + "8½ (1963)", "8½", 1963, + [Movie(422801, "Interpol Code 8", "国際秘密警察 指令第8号", 1963), Movie(520251, "Um 8 Uhr kommt Sadowski", "Um 8 Uhr kommt Sadowski", 1963), Movie(422, "8½", "8½", 1963)], + 422 + }, + // Matched on the original title, the localized one is unrecognizable. + { + "Ściany mają uszy (1966)", "Ściany mają uszy", 1966, + [Movie(1, "Something Else", "Something Else", 1966), Movie(2, "Walls Have Ears", "Ściany mają uszy", 1966)], + 2 + }, + // Regional release dates straddle the new year, so a year that is off by one still matches. + { + "Off by one year", "Some Movie", 2011, + [Movie(1, "Some Movie", "Some Movie", 2015), Movie(2, "Some Movie", "Some Movie", 2010)], + 2 + }, + // Nothing matches the name, so TMDb's own ordering is kept. + { + "A Christmas No. 1 (2021)", "A Christmas No. 1", 2021, + [Movie(878111, "A Christmas Number One", "A Christmas Number One", 2021), Movie(2, "Ten Hours for Christmas", "10 Horas para o Natal", 2021)], + 878111 + }, + // A title that matches always beats one that only shares the year. + { + "Title outranks year", "Some Movie", 2020, + [Movie(1, "A Different Movie", "A Different Movie", 2020), Movie(2, "Some Movie", "Some Movie", 1994)], + 2 + }, + // Without a year the title alone decides, and equally good titles keep TMDb's order. + { + "No year known", "Mulan", 0, + [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020)], + 10674 + }, + // An unparsable name must not throw or reorder anything. + { + "Empty name", " ", 2020, + [Movie(1, "Some Movie", "Some Movie", 1994), Movie(2, "Some Movie", "Some Movie", 2020)], + 1 + } + }; + + private static SearchMovie Movie(int id, string title, string originalTitle, int year) + => new() + { + Id = id, + Title = title, + OriginalTitle = originalTitle, + ReleaseDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc) + }; + + private static SearchTv Series(int id, string name, string originalName, int year) + => new() + { + Id = id, + Name = name, + OriginalName = originalName, + FirstAirDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc) + }; } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs index 96625ae670..6a3dcab57a 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; @@ -7,12 +8,14 @@ using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; using Moq; using Xunit; @@ -99,9 +102,199 @@ public class DtoServiceImageInheritanceTests Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]); } + [Fact] + public void GetBaseItemDtos_MusicAlbums_ResolveInheritedThumbFromArtistBatch_WithoutPerAlbumLookup() + { + var artist = new MusicArtist + { + Id = Guid.NewGuid(), + Name = "Some Artist", + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Thumb, + Path = "/images/artist-thumb.jpg", + DateModified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) + } + ] + }; + + static MusicAlbum MakeAlbum() => new MusicAlbum + { + Id = Guid.NewGuid(), + Name = "Album", + AlbumArtists = ["Some Artist"], + ImageInfos = [] + }; + + var libraryManager = new Mock<ILibraryManager>(); + + // DtoService resolves every album-artist name in ONE batch (GetArtists). The album's inherited + // Thumb/Backdrop images must come from that batch, not a per-album GetArtist/GetItemList lookup + // (the N+1). GetArtist is intentionally left unset: a regression to the per-album path would + // resolve no artist and fail the assertions below. + libraryManager + .Setup(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>())) + .Returns(new Dictionary<string, MusicArtist[]>(StringComparer.OrdinalIgnoreCase) + { + ["Some Artist"] = [artist] + }); + + var dtoService = BuildDtoService(libraryManager); + + var dtos = dtoService.GetBaseItemDtos([MakeAlbum(), MakeAlbum()], new DtoOptions(false)); + + Assert.Equal(2, dtos.Count); + foreach (var dto in dtos) + { + Assert.Equal(artist.Id, dto.ParentThumbItemId); + Assert.Equal("/images/artist-thumb.jpg", dto.ParentThumbImageTag); + } + + // The artist lookup is batched once for the whole set, never once per album. + libraryManager.Verify(x => x.GetArtists(It.IsAny<IReadOnlyList<string>>()), Times.Once); + libraryManager.Verify(x => x.GetArtist(It.IsAny<string>(), It.IsAny<DtoOptions>()), Times.Never); + } + + [Fact] + public void GetBaseItemDtos_Items_ResolvePeopleFromBatch_WithoutPerItemLookup() + { + static MusicAlbum MakeAlbum() => new MusicAlbum + { + Id = Guid.NewGuid(), + Name = "Album", + ImageInfos = [] + }; + + var albumOne = MakeAlbum(); + var albumTwo = MakeAlbum(); + + var libraryManager = new Mock<ILibraryManager>(); + + // DtoService resolves people for every item in ONE batch (GetPeopleByItems) before the + // per-item loop. A regression to the per-item path would call GetPeople(BaseItem) once per + // item (the N+1); it is intentionally left unset so such a regression fails here. + libraryManager + .Setup(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new Dictionary<Guid, IReadOnlyList<PersonInfo>> + { + [albumOne.Id] = [new PersonInfo { ItemId = albumOne.Id, Name = "Some Actor", Type = PersonKind.Actor }], + [albumTwo.Id] = [new PersonInfo { ItemId = albumTwo.Id, Name = "Some Actor", Type = PersonKind.Actor }] + }); + + // AttachPeople still resolves each distinct name to its Person entity to attach images. + libraryManager + .Setup(x => x.GetPerson("Some Actor")) + .Returns(new Person { Id = Guid.NewGuid(), Name = "Some Actor" }); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.People] }; + var dtos = dtoService.GetBaseItemDtos([albumOne, albumTwo], options); + + Assert.Equal(2, dtos.Count); + foreach (var dto in dtos) + { + Assert.NotNull(dto.People); + Assert.Single(dto.People); + Assert.Equal("Some Actor", dto.People[0].Name); + } + + // People are batched once for the whole set, never once per item. + libraryManager.Verify(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + libraryManager.Verify(x => x.GetPeople(It.IsAny<BaseItem>()), Times.Never); + } + + [Fact] + public void GetBaseItemDtos_Videos_ResolveMediaSourceCountFromBatch_WithoutPerItemLookup() + { + static Movie MakeMovie() => new Movie + { + Id = Guid.NewGuid(), + Name = "Movie", + ImageInfos = [] + }; + + var movieOne = MakeMovie(); + var movieTwo = MakeMovie(); + + var libraryManager = new Mock<ILibraryManager>(); + + // DtoService detects which videos own alternate versions in ONE batch + // (GetItemIdsWithAlternateVersions) before the per-item loop. Videos absent from that set have a + // single media source, so the per-item GetLinkedAlternateVersions/GetLocalAlternateVersionIds + // queries (the N+1) must be skipped entirely. Here neither movie has alternate versions. + libraryManager + .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new HashSet<Guid>()); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] }; + var dtos = dtoService.GetBaseItemDtos([movieOne, movieTwo], options); + + Assert.Equal(2, dtos.Count); + + // A single media source is the default, so the count is left unset (the client treats null as one). + foreach (var dto in dtos) + { + Assert.Null(dto.MediaSourceCount); + } + + // The alternate-version check is batched once for the whole set, and the per-item lookups are + // never reached because the batch already ruled out alternate versions. + libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + libraryManager.Verify(x => x.GetLinkedAlternateVersions(It.IsAny<Video>()), Times.Never); + libraryManager.Verify(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()), Times.Never); + } + + [Fact] + public void GetBaseItemDtos_VideoInAlternateVersionBatch_ResolvesRealCount() + { + var movie = new Movie + { + Id = Guid.NewGuid(), + Name = "Movie", + ImageInfos = [] + }; + + var libraryManager = new Mock<ILibraryManager>(); + + // This movie IS in the batch set, so the fast path must not short-circuit it: the per-item + // lookups still run and the count is computed exactly as it was before batching. Two linked + // alternate versions plus the movie itself is a count of three. + libraryManager + .Setup(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>())) + .Returns(new HashSet<Guid> { movie.Id }); + libraryManager + .Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())) + .Returns([new Movie { Id = Guid.NewGuid() }, new Movie { Id = Guid.NewGuid() }]); + libraryManager + .Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())) + .Returns([]); + + var dtoService = BuildDtoService(libraryManager); + + var options = new DtoOptions(false) { Fields = [ItemFields.MediaSourceCount] }; + var dtos = dtoService.GetBaseItemDtos([movie], options); + + Assert.Single(dtos); + Assert.Equal(3, dtos[0].MediaSourceCount); + libraryManager.Verify(x => x.GetItemIdsWithAlternateVersions(It.IsAny<IReadOnlyList<Guid>>()), Times.Once); + } + private static DtoService BuildDtoService(BaseItem displayParent) { var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(x => x.GetItemById(displayParent.Id)) + .Returns(displayParent); + return BuildDtoService(libraryManager); + } + + private static DtoService BuildDtoService(Mock<ILibraryManager> libraryManager) + { var userDataManager = new Mock<IUserDataManager>(); var imageProcessor = new Mock<IImageProcessor>(); var providerManager = new Mock<IProviderManager>(); @@ -113,14 +306,14 @@ public class DtoServiceImageInheritanceTests var chapterManager = new Mock<IChapterManager>(); var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>(); - libraryManager - .Setup(x => x.GetItemById(displayParent.Id)) - .Returns(displayParent); - imageProcessor .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path); + // Video.IsActiveRecording() dereferences this static during DTO building. + Video.RecordingsManager = recordingsManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + return new DtoService( logger.Object, libraryManager.Object, diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index 9c247d54b9..fd84cfb497 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -1,5 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; using Emby.Server.Implementations.Dto; +using Jellyfin.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; @@ -21,11 +27,13 @@ namespace Jellyfin.Server.Implementations.Tests.Dto; public class DtoServiceTests { private readonly Mock<ILibraryManager> _libraryManagerMock; + private readonly Mock<IUserDataManager> _userDataManagerMock; private readonly DtoService _dtoService; public DtoServiceTests() { _libraryManagerMock = new Mock<ILibraryManager>(); + _userDataManagerMock = new Mock<IUserDataManager>(); var imageProcessor = new Mock<IImageProcessor>(); // Deterministic tag derived from the image so each item gets a distinct, assertable tag. @@ -42,7 +50,7 @@ public class DtoServiceTests _dtoService = new DtoService( NullLogger<DtoService>.Instance, _libraryManagerMock.Object, - new Mock<IUserDataManager>().Object, + _userDataManagerMock.Object, imageProcessor.Object, new Mock<IProviderManager>().Object, new Mock<IRecordingsManager>().Object, @@ -105,6 +113,147 @@ public class DtoServiceTests Assert.Null(dto.ParentPrimaryImageItemId); } + [Fact] + public void GetBaseItemDtos_SeasonWithNoRealEpisodes_ReportsVirtualEpisodesAsChildCount() + { + // No episode has aired yet, so RecursiveItemCount is 0. ChildCount must still report the + // virtual episodes clients get back for the season. This deliberately does not track + // Season.IsVirtualItem: that flag is recomputed only on a full refresh, so a season can + // carry it while already holding real episodes. + var (season, user) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10); + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount, ItemFields.RecursiveItemCount] }; + + var dto = _dtoService.GetBaseItemDtos([season], options, user, skipVisibilityCheck: true)[0]; + + Assert.Equal(0, dto.RecursiveItemCount); + Assert.Equal(10, dto.ChildCount); + } + + [Fact] + public void GetBaseItemDtos_SeasonWithRealEpisodes_KeepsRecursiveItemCountAsChildCount() + { + var (season, user) = BuildSeason(playedCount: 2, totalCount: 9, childCount: 11); + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount, ItemFields.RecursiveItemCount] }; + + var dto = _dtoService.GetBaseItemDtos([season], options, user, skipVisibilityCheck: true)[0]; + + Assert.Equal(9, dto.RecursiveItemCount); + // The shortcut still wins over the batched child count, which also counts virtual episodes. + Assert.Equal(9, dto.ChildCount); + } + + [Fact] + public void GetBaseItemDtos_NoUser_SkipsTheChildCountBatch() + { + // A child count is attached only to a user's dto, so with no user the batch is work whose + // result nothing reads - and it is a grouped count over every item, not a cheap one. + var (season, _) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10); + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + var dto = _dtoService.GetBaseItemDtos([season], options, user: null, skipVisibilityCheck: true)[0]; + + Assert.Null(dto.ChildCount); + _libraryManagerMock.Verify( + x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()), + Times.Never); + } + + [Fact] + public void GetBaseItemDtos_GroupedMoviesView_CountsEveryLibraryGroupedIntoIt() + { + // The view has no library of its own, so its count is the sum over the libraries the user + // grouped into it - including an untyped one, which the view also shows. + var user = new User("user", "auth-provider", "reset-provider"); + var grouped = BuildLibrary(CollectionType.movies); + var untyped = BuildLibrary(null); + var shows = BuildLibrary(CollectionType.tvshows); + var ungrouped = BuildLibrary(CollectionType.movies); + user.SetPreference(PreferenceKind.GroupedFolders, [grouped.Id, untyped.Id, shows.Id]); + + // A real root folder would resolve its children through the library it does not have here. + var rootFolder = new Mock<Folder>(); + rootFolder + .Setup(x => x.GetChildren(user, true, It.IsAny<InternalItemsQuery>())) + .Returns<User, bool, InternalItemsQuery>((_, _, _) => [grouped, untyped, shows, ungrouped]); + _libraryManagerMock.Setup(x => x.GetUserRootFolder()).Returns(rootFolder.Object); + + IReadOnlyList<Guid>? counted = null; + _libraryManagerMock + .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>())) + .Callback<IReadOnlyList<Guid>, User?>((ids, _) => counted = ids) + .Returns<IReadOnlyList<Guid>, User?>((ids, _) => ids.ToDictionary(id => id, _ => 4)); + + var view = new UserView { Id = Guid.NewGuid(), Name = "Movies", ViewType = CollectionType.movies }; + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + var dto = _dtoService.GetBaseItemDtos([view], options, user, skipVisibilityCheck: true)[0]; + + Assert.Equal(grouped.PhysicalFolderIds.Concat(untyped.PhysicalFolderIds), counted); + Assert.Equal(16, dto.ChildCount); + } + + [Fact] + public void GetBaseItemDtos_SubViewOfALibrary_DoesNotCountTheLibrary() + { + // A sub-view hangs off the library the view was built over, but it holds a query over it, + // not its children: counting the library would report every movie as "Continue Watching". + var user = new User("user", "auth-provider", "reset-provider"); + var library = BuildLibrary(CollectionType.movies); + _libraryManagerMock.Setup(x => x.GetItemById(library.Id)).Returns(library); + + // The fallback count a sub-view falls through to runs a query of its own. + _libraryManagerMock + .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns([]); + + var subView = new UserView + { + Id = Guid.NewGuid(), + Name = "Continue Watching", + ViewType = CollectionType.movieresume, + DisplayParentId = library.Id + }; + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] }; + + _dtoService.GetBaseItemDtos([subView], options, user, skipVisibilityCheck: true); + + _libraryManagerMock.Verify( + x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()), + Times.Never); + } + + private static CollectionFolder BuildLibrary(CollectionType? collectionType) + { + return new CollectionFolder + { + Id = Guid.NewGuid(), + CollectionType = collectionType, + PhysicalFolderIds = [Guid.NewGuid(), Guid.NewGuid()] + }; + } + + private (Season Season, User User) BuildSeason(int playedCount, int totalCount, int childCount) + { + var user = new User("user", "auth-provider", "reset-provider"); + var season = new Season { Id = Guid.NewGuid(), Name = "Season 2", SeriesId = Guid.NewGuid() }; + + _userDataManagerMock + .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), user)) + .Returns(new Dictionary<Guid, UserItemData> { [season.Id] = new UserItemData { Key = "key" } }); + _userDataManagerMock + .Setup(x => x.GetResumeUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), user)) + .Returns(new Dictionary<Guid, VersionResumeData>()); + + _libraryManagerMock + .Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user)) + .Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) }); + _libraryManagerMock + .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>())) + .Returns(new Dictionary<Guid, int> { [season.Id] = childCount }); + + return (season, user); + } + private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true) { // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the diff --git a/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs new file mode 100644 index 0000000000..54fec0a0d3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Entities; + +public sealed class UserViewBuilderTests +{ + private static readonly User _user = new("view-filter-test", "provider", "reset"); + + [Fact] + public void Filter_IsPlayed_CountsAMovieWatchedOnAnAlternateVersionAsPlayed() + { + // The primary carries no played row of its own; the version that was watched is another file. + var onlyWatchedOnAlternate = new Movie { Id = Guid.NewGuid(), Name = "Watched as a second cut" }; + var watched = new Movie { Id = Guid.NewGuid(), Name = "Watched outright" }; + var unwatched = new Movie { Id = Guid.NewGuid(), Name = "Not watched" }; + + var items = new BaseItem[] { onlyWatchedOnAlternate, watched, unwatched }; + + var userDataManager = new Mock<IUserDataManager>(); + userDataManager + .Setup(m => m.GetUserData(_user, It.IsAny<BaseItem>())) + .Returns((User _, BaseItem item) => new UserItemData { Key = item.Id.ToString("N"), Played = item.Id.Equals(watched.Id) }); + userDataManager + .Setup(m => m.GetResumeUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), _user)) + .Returns(new Dictionary<Guid, VersionResumeData> + { + [onlyWatchedOnAlternate.Id] = new(Guid.NewGuid(), new UserItemData { Key = "alternate", Played = true }) + }); + + var libraryManager = new Mock<ILibraryManager>(); + + var played = UserViewBuilder.Filter( + items, + _user, + new InternalItemsQuery(_user) { IsPlayed = true }, + userDataManager.Object, + libraryManager.Object).ToList(); + + var unplayed = UserViewBuilder.Filter( + items, + _user, + new InternalItemsQuery(_user) { IsPlayed = false }, + userDataManager.Object, + libraryManager.Object).ToList(); + + // The alternate's playback settles the movie, exactly as the item's own dto reports it. + Assert.Equal([onlyWatchedOnAlternate.Id, watched.Id], played.Select(i => i.Id)); + Assert.Equal([unwatched.Id], unplayed.Select(i => i.Id)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs new file mode 100644 index 0000000000..cdb261de8d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.EntryPoints; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.EntryPoints; + +public class LibraryChangedNotifierTests +{ + // How long a test waits for the notifier's timer callback to run. Generous: the assertions are + // about a batch being sent at all, not about how promptly. + private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15); + + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly Mock<IServerConfigurationManager> _configurationManager = new(); + private readonly Mock<ISessionManager> _sessionManager = new(); + private readonly Mock<IUserManager> _userManager = new(); + private readonly Mock<IProviderManager> _providerManager = new(); + private readonly ServerConfiguration _configuration = new(); + + private int _flushCount; + + public LibraryChangedNotifierTests() + { + _configurationManager.SetupGet(e => e.Configuration).Returns(_configuration); + + // Reading the session list is the first thing a flush does, so it stands in for "a batch was + // sent" without having to mock a whole user library behind it. + _sessionManager.SetupGet(e => e.Sessions) + .Returns(() => + { + Interlocked.Increment(ref _flushCount); + return []; + }); + } + + [Fact] + public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow() + { + // Long enough that only the size cap can close the batch. + _configuration.LibraryUpdateDuration = 3600; + + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++) + { + RaiseItemUpdated(); + } + + Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + [Fact] + public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow() + { + // A scan changes items continuously. The window must run from the first change of a batch, or + // the batch never closes and holds every item it named alive for the length of the scan. + _configuration.LibraryUpdateDuration = 1; + + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0) + { + // Well below the window, and well below the size cap over the whole loop. + RaiseItemUpdated(); + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + private LibraryChangedNotifier CreateNotifier() + => new( + _libraryManager.Object, + _configurationManager.Object, + _sessionManager.Object, + _userManager.Object, + NullLogger<LibraryChangedNotifier>.Instance, + _providerManager.Object); + + // A folder passes the notifier's item filter without needing any of BaseItem's static services. + private void RaiseItemUpdated() + => _libraryManager.Raise( + e => e.ItemUpdated += null, + _libraryManager.Object, + new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } }); + + private async Task<bool> WaitForFlushAsync(int expected) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout) + { + if (Volatile.Read(ref _flushCount) >= expected) + { + return true; + } + + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + return false; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs new file mode 100644 index 0000000000..0274398f89 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.EntryPoints; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Session; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.EntryPoints; + +public class UserDataChangeNotifierTests +{ + // How long a test waits for the notifier's timer callback to run. Generous: the assertions are + // about a batch being sent at all, not about how promptly. + private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15); + + private readonly Mock<IUserDataManager> _userDataManager = new(); + private readonly Mock<ISessionManager> _sessionManager = new(); + private readonly Mock<IUserManager> _userManager = new(); + + private int _flushCount; + + public UserDataChangeNotifierTests() + { + _sessionManager + .Setup(e => e.SendMessageToUserSessions( + It.IsAny<System.Collections.Generic.List<Guid>>(), + SessionMessageType.UserDataChanged, + It.IsAny<Func<UserDataChangeInfo>>(), + It.IsAny<CancellationToken>())) + .Callback(() => Interlocked.Increment(ref _flushCount)) + .Returns(Task.CompletedTask); + } + + [Fact] + public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow() + { + // A scan changes user data continuously. The window must run from the first change of a batch, + // or the batch never closes and holds every item it named alive for the length of the scan. + var notifier = CreateNotifier(); + await notifier.StartAsync(TestContext.Current.CancellationToken); + + var userId = Guid.NewGuid(); + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0) + { + // Well below the window, and well below the size cap over the whole loop. + RaiseUserDataSaved(userId); + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving."); + + await notifier.StopAsync(TestContext.Current.CancellationToken); + notifier.Dispose(); + } + + private UserDataChangeNotifier CreateNotifier() + => new(_userDataManager.Object, _sessionManager.Object, _userManager.Object); + + // A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the + // notifier ignores outright. + private void RaiseUserDataSaved(Guid userId) + => _userDataManager.Raise( + e => e.UserDataSaved += null, + _userDataManager.Object, + new UserDataSaveEventArgs + { + UserId = userId, + SaveReason = UserDataSaveReason.UpdateUserRating, + Item = new Folder { Id = Guid.NewGuid() } + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs new file mode 100644 index 0000000000..66c392a6ad --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.FullSystemBackup; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.SystemBackupService; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup; + +/// <summary> +/// Tests for <see cref="BackupService"/>, in particular that a single row of corrupt +/// <see cref="KeyframeData"/> (e.g. malformed <c>KeyframeTicks</c> JSON) does not abort +/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216. +/// </summary> +public sealed class BackupServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly string _testRoot; + private readonly string _backupPath; + private readonly string _configurationDirectoryPath; + + public BackupServiceTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + // Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted + // windows-latest runners, the system temp directory lives on the constrained C: drive, which can have + // less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory + // is under the repo checkout (the much larger D: drive on Windows runners) on all platforms. + _testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N")); + _backupPath = Path.Combine(_testRoot, "Backup"); + _configurationDirectoryPath = Path.Combine(_testRoot, "Config"); + Directory.CreateDirectory(_backupPath); + Directory.CreateDirectory(_configurationDirectoryPath); + } + + public void Dispose() + { + _connection.Dispose(); + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + [Fact] + public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup() + { + var cancellationToken = TestContext.Current.CancellationToken; + var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + await using (var ctx = CreateDbContext()) + { + // A healthy item + keyframe row, written the normal way. + ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie")); + ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie")); + await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true); + + ctx.KeyframeData.Add(new KeyframeData + { + ItemId = validItemId, + TotalDuration = 60_000, + KeyframeTicks = [0, 1000, 2000] + }); + await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true); + + // Simulate a corrupted database row: truncated JSON array for KeyframeTicks, + // written directly via SQL to bypass EF's normal (well-formed) write path. + await ctx.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})", + cancellationToken).ConfigureAwait(true); + } + + var backupService = CreateBackupService(); + + var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true); + + Assert.True(File.Exists(manifest.Path)); + + using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true); + var keyframeEntry = archive.GetEntry("Database/KeyframeData.json"); + Assert.NotNull(keyframeEntry); + + await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true); + using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true); + + var rows = document.RootElement.EnumerateArray().ToList(); + + // The corrupt row must be skipped, but the valid row must still make it into the backup. + var singleRow = Assert.Single(rows); + Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid()); + } + + private BackupService CreateBackupService() + { + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext); + + var applicationHost = new Mock<IServerApplicationHost>(); + applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0)); + + var applicationPaths = new Mock<IServerApplicationPaths>(); + applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath); + applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath); + applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data")); + applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root")); + applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata")); + applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault")); + + var jellyfinDatabaseProvider = new Mock<IJellyfinDatabaseProvider>(); + jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask); + jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny<JellyfinDbContext>(), It.IsAny<System.Collections.Generic.IEnumerable<string>>())).Returns(Task.CompletedTask); + + var applicationLifetime = new Mock<IHostApplicationLifetime>(); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.IsScanRunning).Returns(false); + + return new BackupService( + NullLogger<BackupService>.Instance, + factory.Object, + applicationHost.Object, + applicationPaths.Object, + jellyfinDatabaseProvider.Object, + applicationLifetime.Object, + libraryManager.Object); + } + + private static BaseItemEntity CreateMovieEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = "Movie", + Name = name, + PresentationUniqueKey = id.ToString("N"), + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs index 22667ee82d..b9ae16255e 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs @@ -1,7 +1,11 @@ using System; using System.Buffers; using System.IO; +using System.Net.WebSockets; +using System.Text; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Emby.Server.Implementations.HttpServer; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -48,6 +52,92 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer Assert.Throws<JsonException>(() => con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed)); } + [Fact] + public async Task ReceiveAsync_SocketTornDownWhileAnswering_RaisesClosedWithoutThrowing() + { + // The keep-alive watchdog can dispose a connection while the receive loop is + // answering a message on it. The failing answer must not escape into the request + // handler, as that would skip the Closed event the session needs to release it. + var socket = new DisposedOnSendWebSocket(Encoding.UTF8.GetBytes("{\"MessageType\":\"KeepAlive\"}")); + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), socket, null!, null!) + { + OnReceive = _ => Task.CompletedTask + }; + + var closed = false; + con.Closed += (_, _) => closed = true; + + await con.ReceiveAsync(TestContext.Current.CancellationToken); + + Assert.True(closed); + Assert.Equal(1, socket.SendAttempts); + } + + /// <summary> + /// A socket that hands out a single message and then behaves like a socket that was + /// disposed underneath the receive loop. + /// </summary> + internal sealed class DisposedOnSendWebSocket : WebSocket + { + private readonly byte[] _message; + private bool _received; + + public DisposedOnSendWebSocket(byte[] message) + { + _message = message; + } + + public int SendAttempts { get; private set; } + + public override WebSocketCloseStatus? CloseStatus => null; + + public override string? CloseStatusDescription => null; + + public override string? SubProtocol => null; + + public override WebSocketState State => SendAttempts == 0 ? WebSocketState.Open : WebSocketState.Closed; + + public override void Abort() + { + } + + public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) + => Task.CompletedTask; + + public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) + => Task.CompletedTask; + + public override void Dispose() + { + } + + public override ValueTask<ValueWebSocketReceiveResult> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_received, this); + + _received = true; + _message.CopyTo(buffer); + return ValueTask.FromResult(new ValueWebSocketReceiveResult(_message.Length, WebSocketMessageType.Text, true)); + } + + public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public override ValueTask SendAsync(ReadOnlyMemory<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) + => throw FailSend(); + + public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) + => throw FailSend(); + + private WebSocketException FailSend() + { + SendAttempts++; + return new WebSocketException( + WebSocketError.InvalidState, + "The WebSocket is in an invalid state ('Closed') for this operation. Valid states are: 'Open, CloseReceived'"); + } + } + internal sealed class BufferSegment : ReadOnlySequenceSegment<byte> { public BufferSegment(Memory<byte> memory) diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs new file mode 100644 index 0000000000..fd5f8e4160 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Emby.Server.Implementations.IO; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.IO; + +public class FileRefresherTests +{ + [Fact] + public async Task ProcessPathChanges_PathLookupThrows_StillRefreshesRemainingPaths() + { + var tempDir = Directory.CreateTempSubdirectory("filerefresher"); + try + { + // Ordered so the failing path is dequeued first. + var failingPath = Path.Combine(tempDir.FullName, "failing", "episode.mkv"); + var workingPath = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "working")).FullName; + + var workingItem = new Folder { Path = workingPath, Name = "working" }; + var workingItemFound = new TaskCompletionSource(); + + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose); + libraryManager.Setup(x => x.FindByPath(failingPath, null)) + .Throws(new ObjectDisposedException("IServiceProvider")); + libraryManager.Setup(x => x.FindByPath(workingPath, null)) + .Returns(workingItem) + .Callback(() => workingItemFound.TrySetResult()); + + var configurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Loose); + configurationManager.Setup(x => x.Configuration) + .Returns(new ServerConfiguration { LibraryMonitorDelay = 1 }); + + using var refresher = new FileRefresher( + failingPath, + configurationManager.Object, + libraryManager.Object, + NullLogger.Instance); + refresher.AddPath(workingPath); + + await workingItemFound.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + libraryManager.Verify(x => x.FindByPath(failingPath, null), Times.Once); + } + finally + { + tempDir.Delete(true); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index 6cadfacce8..b39ca83483 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -100,6 +100,41 @@ public partial class ManagedFileSystemTests Assert.Equal(expectedFileName, _sut.GetValidFilename(filename)); } + [Theory] + [InlineData("/media", "/media/tv", true)] + [InlineData("/media", "/media/tv/show/episode.mkv", true)] + [InlineData("/media/", "/media/tv", true)] + [InlineData("/", "/media", true)] + [InlineData("/media", "/media", false)] + [InlineData("/media", "/media/", true)] + [InlineData("/media", "/data/media/tv", false)] + [InlineData("/media", "/mediastuff/tv", false)] + [InlineData("/data/media", "/data/media/tv", true)] + [InlineData("/media/tv", "/media", false)] + [InlineData("/MEDIA", "/media/tv", false)] + public void ContainsSubPath_Unix_ReturnsExpected(string parentPath, string path, bool expected) + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix-only test"); + + Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path)); + } + + [Theory] + [InlineData(@"C:\media", @"C:\media\tv", true)] + [InlineData(@"C:\media\", @"C:\media\tv", true)] + [InlineData(@"C:\", @"C:\media", true)] + [InlineData(@"C:\media", @"C:\media", false)] + [InlineData(@"C:\media", @"C:\data\media\tv", false)] + [InlineData(@"C:\media", @"C:\mediastuff\tv", false)] + [InlineData(@"C:\MEDIA", @"C:\media\tv", true)] + [InlineData(@"C:\media", @"C:\media/tv", true)] + public void ContainsSubPath_Windows_ReturnsExpected(string parentPath, string path, bool expected) + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows-only test"); + + Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path)); + } + [Fact] public void GetFileInfo_DanglingSymlink_ExistsFalse() { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs index c8aa14af58..2d520f8b8b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -4,11 +4,6 @@ using System; using System.Linq; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Item; @@ -18,22 +13,10 @@ namespace Jellyfin.Server.Implementations.Tests.Item; /// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate /// and evaluate correctly on the SQLite provider. /// </summary> -public sealed class AlternateVersionQueryTranslationTests : IDisposable +public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture { - private readonly SqliteConnection _connection; - private readonly DbContextOptions<JellyfinDbContext> _dbOptions; - public AlternateVersionQueryTranslationTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() - .UseSqlite(_connection) - .Options; - - using var ctx = CreateDbContext(); - ctx.Database.EnsureCreated(); } [Fact] @@ -60,7 +43,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable .Where(e => seededIds.Contains(e.Id)) .Where(e => inProgressIds.Contains(e.Id)) .Where(e => !ctx.BaseItems - .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Where(s => s.Id != e.Id + && inProgressIds.Contains(s.Id) + && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) @@ -110,7 +95,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable .Where(e => seededIds.Contains(e.Id)) .Where(e => inProgressIds.Contains(e.Id)) .Where(e => !ctx.BaseItems - .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Where(s => s.Id != e.Id + && inProgressIds.Contains(s.Id) + && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) .Any(s => inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) @@ -216,18 +203,4 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable ctx.SaveChanges(); return (user.Id, primary.Id, versionA.Id, versionB.Id); } - - private JellyfinDbContext CreateDbContext() - { - return new JellyfinDbContext( - _dbOptions, - NullLogger<JellyfinDbContext>.Instance, - new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), - new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); - } - - public void Dispose() - { - _connection.Dispose(); - } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs new file mode 100644 index 0000000000..abe1e59496 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the user data rows <see cref="BaseItemMapper"/> hands the domain item. A domain item is +/// held for as long as its folder holds it, so a row that still points back at the entity it was +/// read with would keep that entity - and everything loaded alongside it - alive with it. +/// </summary> +public class BaseItemMapperUserDataTests +{ + [Fact] + public void Map_CopiesUserDataWithoutTheEntityGraphBehindIt() + { + var itemId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var user = new User("someone", "Default", "Default"); + var entity = new BaseItemEntity { Id = itemId, Type = "MediaBrowser.Controller.Entities.TV.Episode" }; + + var row = new UserData + { + ItemId = itemId, + Item = entity, + UserId = userId, + User = user, + CustomDataKey = "key", + PlayCount = 3, + PlaybackPositionTicks = 1234, + IsFavorite = true, + Played = true, + Rating = 7.5, + LastPlayedDate = new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc), + AudioStreamIndex = 1, + SubtitleStreamIndex = 2, + Likes = true + }; + + entity.UserData = [row]; + + var dto = BaseItemMapper.Map(entity, new Folder(), null); + + var mapped = Assert.Single(dto.UserData); + Assert.Null(mapped.Item); + Assert.Null(mapped.User); + + // The values callers actually read still come through. + Assert.Equal(itemId, mapped.ItemId); + Assert.Equal(userId, mapped.UserId); + Assert.Equal("key", mapped.CustomDataKey); + Assert.Equal(3, mapped.PlayCount); + Assert.Equal(1234, mapped.PlaybackPositionTicks); + Assert.True(mapped.IsFavorite); + Assert.True(mapped.Played); + Assert.Equal(7.5, mapped.Rating); + Assert.Equal(row.LastPlayedDate, mapped.LastPlayedDate); + Assert.Equal(1, mapped.AudioStreamIndex); + Assert.Equal(2, mapped.SubtitleStreamIndex); + Assert.True(mapped.Likes); + } + + [Fact] + public void Map_WithoutUserData_YieldsAnEmptyCollection() + { + var entity = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.Folder" + }; + + var dto = BaseItemMapper.Map(entity, new Folder(), null); + + Assert.Empty(dto.UserData); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs new file mode 100644 index 0000000000..298340d1b0 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs @@ -0,0 +1,215 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Querying; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// The by-name listings count what a cleaned value is attached to by joining ItemValuesMap to +/// BaseItems. One item can reach the same clean value through more than one value row, so the +/// join has to be counted per distinct item; counting rows reports a multiple of the truth. +/// </summary> +public sealed class BaseItemRepositoryByNameItemCountsTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryByNameItemCountsTests() + { + _itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(_itemTypeLookup); + } + + [Fact] + public void GetAllArtists_AlbumCreditedAsArtistAndAlbumArtist_CountsTheAlbumOnce() + { + // GetAllArtists spans both credit types, so an album whose artist is also its album artist + // reaches the one clean value through two rows. + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.AlbumArtist); + + var result = _repository.GetAllArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + Assert.Equal(1, counts.ItemCount); + } + + [Fact] + public void GetAlbumArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + // The shape that actually reaches users: only (Type, Value) is unique, so two differently + // cased credits of one type both clean down to a single name and both map the album. + SeedArtistWithAlbum(ItemValueType.AlbumArtist, ItemValueType.AlbumArtist); + + var result = _repository.GetAlbumArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Fact] + public void GetArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() + { + SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.Artist); + + var result = _repository.GetArtists(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, counts.AlbumCount); + } + + [Theory] + [InlineData(BaseItemKind.Book)] + [InlineData(BaseItemKind.BoxSet)] + public void GetGenres_TaggedBookOrBoxSet_CountsIt(BaseItemKind kind) + { + // The listing used to dispatch only nine of the eleven counted types, so a genre on a book + // or a box set read as zero in a list and as one on the genre's own page. + SeedGenreWith(kind); + + var result = _repository.GetGenres(CreateCountingQuery()); + + var (_, counts) = Assert.Single(result.Items); + Assert.NotNull(counts); + Assert.Equal(1, kind == BaseItemKind.Book ? counts.BookCount : counts.BoxSetCount); + Assert.Equal(1, counts.ItemCount); + } + + /// <summary> + /// Seeds one genre carried by a single item of the given kind. + /// </summary> + /// <param name="kind">The kind of the tagged item.</param> + private void SeedGenreWith(BaseItemKind kind) + { + const string Name = "Reference"; + const string CleanName = "reference"; + + using var ctx = CreateDbContext(); + + var genreId = Guid.Parse("dddddddd-0000-0000-0000-000000000001"); + var taggedId = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = genreId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = genreId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var tagged = new BaseItemEntity + { + Id = taggedId, + Type = _itemTypeLookup.BaseItemKindNames[kind], + Name = "Tagged", + CleanName = "tagged", + PresentationUniqueKey = taggedId.ToString("N"), + IsFolder = false, + IsVirtualItem = false + }; + ctx.BaseItems.Add(tagged); + + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse("ffffffff-0000-0000-0000-000000000001"), + Type = ItemValueType.Genre, + Value = Name, + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = taggedId, + ItemValueId = itemValue.ItemValueId, + Item = tagged, + ItemValue = itemValue + }); + + ctx.SaveChanges(); + } + + private static InternalItemsQuery CreateCountingQuery() + { + return new InternalItemsQuery(new User("test", "auth", "reset")) + { + DtoOptions = new DtoOptions(true) { Fields = [ItemFields.ItemCounts] } + }; + } + + /// <summary> + /// Seeds one artist and a single album mapped to that artist's clean name through two value + /// rows of the given types. + /// </summary> + /// <param name="first">The type of the first value row.</param> + /// <param name="second">The type of the second value row.</param> + private void SeedArtistWithAlbum(ItemValueType first, ItemValueType second) + { + const string Name = "Tangerine Dream"; + const string CleanName = "tangerine dream"; + + using var ctx = CreateDbContext(); + + var artistId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + var albumId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"); + + ctx.BaseItems.Add(new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = Name, + CleanName = CleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }); + + var album = new BaseItemEntity + { + Id = albumId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum], + Name = "Phaedra", + CleanName = "phaedra", + PresentationUniqueKey = albumId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + ctx.BaseItems.Add(album); + + var types = new[] { first, second }; + for (var i = 0; i < types.Length; i++) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"), + Type = types[i], + // Distinct values, one clean name: exactly what the unique index permits. + Value = i == 0 ? Name : Name.ToUpperInvariant(), + CleanValue = CleanName + }; + + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = album, + ItemValue = itemValue + }); + } + + ctx.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs new file mode 100644 index 0000000000..0cee47f660 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// The by-name endpoints (artists, album artists, genres, studios) all funnel through +/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count +/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total. +/// </summary> +public sealed class BaseItemRepositoryByNameTotalCountTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly ItemTypeLookup _itemTypeLookup; + + public BaseItemRepositoryByNameTotalCountTests() + { + _itemTypeLookup = new ItemTypeLookup(); + + _repository = CreateBaseItemRepository(_itemTypeLookup); + } + + [Fact] + public void GetArtists_WithoutLimit_ReportsTotalRecordCount() + { + SeedArtists(3); + + var result = _repository.GetArtists(CreateQuery(limit: null)); + + Assert.Equal(3, result.Items.Count); + Assert.Equal(3, result.TotalRecordCount); + } + + [Fact] + public void GetArtists_WithLimit_ReportsTotalBeyondThePage() + { + SeedArtists(3); + + var result = _repository.GetArtists(CreateQuery(limit: 2)); + + Assert.Equal(2, result.Items.Count); + Assert.Equal(3, result.TotalRecordCount); + } + + [Fact] + public void GetArtists_TotalRecordCountDisabled_StaysZero() + { + SeedArtists(3); + + var query = CreateQuery(limit: null); + query.EnableTotalRecordCount = false; + + var result = _repository.GetArtists(query); + + Assert.Equal(3, result.Items.Count); + Assert.Equal(0, result.TotalRecordCount); + } + + [Fact] + public void GetArtists_WithoutLimit_DoesNotMutateCallerQuery() + { + SeedArtists(1); + + var query = CreateQuery(limit: null); + Assert.True(query.EnableTotalRecordCount); + + _repository.GetArtists(query); + + // The repository used to flip this flag on the caller's own query object, so a + // reused query silently lost its total on every subsequent call. + Assert.True(query.EnableTotalRecordCount); + } + + private static InternalItemsQuery CreateQuery(int? limit) + { + return new InternalItemsQuery(new User("test", "auth", "reset")) + { + Limit = limit + }; + } + + /// <summary> + /// Creates <paramref name="count"/> artists, each credited on one song, which is what + /// makes them visible to the item-value join behind the by-name endpoints. + /// </summary> + private void SeedArtists(int count) + { + using var ctx = CreateDbContext(); + + for (var i = 0; i < count; i++) + { + var name = $"Artist {i}"; + var cleanName = name.ToLowerInvariant(); + + var artistId = Guid.Parse($"aaaaaaaa-0000-0000-0000-{i:D12}"); + var songId = Guid.Parse($"55555555-0000-0000-0000-{i:D12}"); + var valueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"); + + var artist = new BaseItemEntity + { + Id = artistId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], + Name = name, + CleanName = cleanName, + PresentationUniqueKey = artistId.ToString("N"), + IsFolder = true, + IsVirtualItem = false + }; + + var song = new BaseItemEntity + { + Id = songId, + Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio], + Name = $"Song {i}", + CleanName = $"song {i}", + PresentationUniqueKey = songId.ToString("N"), + MediaType = "Audio", + IsFolder = false, + IsVirtualItem = false + }; + + var itemValue = new ItemValue + { + ItemValueId = valueId, + Type = ItemValueType.Artist, + Value = name, + CleanValue = cleanName + }; + + ctx.BaseItems.Add(artist); + ctx.BaseItems.Add(song); + ctx.ItemValues.Add(itemValue); + ctx.ItemValuesMap.Add(new ItemValueMap + { + ItemId = songId, + ItemValueId = valueId, + Item = song, + ItemValue = itemValue + }); + } + + ctx.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs new file mode 100644 index 0000000000..5e045e9f83 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the children query the library scan runs against a folder: a version merged by hand is +/// hidden from ordinary queries, but the scan has to see it or it takes the row for a new item and +/// recreates it, splitting the version group apart again. +/// </summary> +public sealed class BaseItemRepositoryChildrenTests : SqliteDbTestFixture +{ + private static readonly Guid _folderId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _primaryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static readonly Guid _mergedVersionId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + private static readonly Guid _ownedVersionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + private readonly BaseItemRepository _repository; + + public BaseItemRepositoryChildrenTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _repository = CreateBaseItemRepository(itemTypeLookup); + + var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = _folderId, + Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!, + Name = "Movies", + Path = "/movies", + IsFolder = true + }); + ctx.BaseItems.Add(CreateMovie(_primaryId, movieTypeName!, "Big Buck Bunny", "/media1/Big Buck Bunny/bbb-1080p.mp4", null, null)); + ctx.BaseItems.Add(CreateMovie(_mergedVersionId, movieTypeName!, "Big Buck Bunny", "/media2/Big Buck Bunny/bbb-2160p.mp4", _primaryId, null)); + ctx.BaseItems.Add(CreateMovie(_ownedVersionId, movieTypeName!, "Big Buck Bunny - 720p", "/media1/Big Buck Bunny/bbb-720p.mp4", _primaryId, _primaryId)); + ctx.SaveChanges(); + } + + [Fact] + public void GetItemList_ChildrenOfFolder_ExcludesAlternateVersionsByDefault() + { + var result = _repository.GetItemList(new InternalItemsQuery { ParentId = _folderId }); + + var item = Assert.Single(result); + Assert.Equal(_primaryId, item.Id); + } + + [Fact] + public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_KeepsMergedVersion() + { + var result = _repository.GetItemList(new InternalItemsQuery + { + ParentId = _folderId, + IncludeAlternateVersions = true + }); + + Assert.Equal(2, result.Count); + Assert.Contains(result, i => i.Id.Equals(_primaryId)); + Assert.Contains(result, i => i.Id.Equals(_mergedVersionId)); + } + + [Fact] + public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_StillExcludesOwnedVersion() + { + // A version stored next to the file it belongs to is owned by its primary and is never + // resolved on its own, so the scan must not see it as a child of the folder either. + var result = _repository.GetItemList(new InternalItemsQuery + { + ParentId = _folderId, + IncludeAlternateVersions = true + }); + + Assert.DoesNotContain(result, i => i.Id.Equals(_ownedVersionId)); + } + + private static BaseItemEntity CreateMovie(Guid id, string typeName, string name, string path, Guid? primaryVersionId, Guid? ownerId) + { + return new BaseItemEntity + { + Id = id, + Type = typeName, + Name = name, + Path = path, + ParentId = _folderId, + TopParentId = _folderId, + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"), + PrimaryVersionId = primaryVersionId, + OwnerId = ownerId, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs new file mode 100644 index 0000000000..0ca11eb58d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a +/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a +/// linked folder are only reachable by following the link and then the ancestor chain. +/// </summary> +public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _collection = Guid.NewGuid(); + private readonly Guid _series = Guid.NewGuid(); + private readonly Guid _season = Guid.NewGuid(); + private readonly Guid _episode = Guid.NewGuid(); + + // A movie the collection links directly, so the direct-child case is covered alongside the nested one. + private readonly Guid _collectionMovie = Guid.NewGuid(); + + // In the same library but outside the collection, as the control the assertions are read against. + private readonly Guid _otherSeries = Guid.NewGuid(); + private readonly Guid _otherEpisode = Guid.NewGuid(); + + public BaseItemRepositoryDescendantFilterTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void DescendantOfId_ReachesEpisodesOfALinkedSeries() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Episode] + }); + + Assert.Equal([_episode], ids); + } + + [Fact] + public void DescendantOfId_ReturnsEveryLevelBelowTheCollection() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet(); + + Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order()); + } + + [Fact] + public void DescendantOfId_KeepsDirectlyLinkedChildren() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery + { + DescendantOfId = _collection, + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal([_collectionMovie], ids); + } + + [Fact] + public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() }); + + Assert.Empty(ids); + } + + private void Seed(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" }); + context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" }); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. + AddAncestors(context, _series, _library); + AddAncestors(context, _season, _series, _library); + AddAncestors(context, _episode, _season, _series, _library); + AddAncestors(context, _collectionMovie, _library); + AddAncestors(context, _otherSeries, _library); + AddAncestors(context, _otherEpisode, _otherSeries, _library); + + AddLink(context, _series, 0); + AddLink(context, _collectionMovie, 1); + + context.SaveChanges(); + } + + private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _collection, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs index 083f725db9..9238ec9fd1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs @@ -3,64 +3,30 @@ using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; using Xunit; using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; namespace Jellyfin.Server.Implementations.Tests.Item; -public sealed class BaseItemRepositoryGroupingTests : IDisposable +public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture { - private readonly SqliteConnection _connection; - private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private readonly BaseItemRepository _repository; private readonly string _movieTypeName; + private readonly string _folderTypeName; public BaseItemRepositoryGroupingTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() - .UseSqlite(_connection) - .Options; - - using (var ctx = CreateDbContext()) - { - ctx.Database.EnsureCreated(); - } - - var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); - var itemTypeLookup = new ItemTypeLookup(); _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]; - var serverConfigurationManager = new Mock<IServerConfigurationManager>(); - serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); - - _repository = new BaseItemRepository( - factory.Object, - new Mock<IServerApplicationHost>().Object, - itemTypeLookup, - serverConfigurationManager.Object, - NullLogger<BaseItemRepository>.Instance); - } - - public void Dispose() - { - _connection.Dispose(); + _repository = CreateBaseItemRepository(itemTypeLookup); } [Fact] @@ -106,6 +72,118 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable Assert.Equal(firstId, item.Id); } + [Fact] + public void GetItemList_LibraryWithoutThePrimaryOfTheGroup_KeepsTheVersionVisible() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + var sameLibraryPrimaryId = Guid.Parse("55555555-5555-5555-5555-555555555555"); + var sameLibraryVersionId = Guid.Parse("66666666-6666-6666-6666-666666666666"); + + SeedCrossLibraryGroup(primaryId, versionId, sameLibraryPrimaryId, sameLibraryVersionId); + + var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId)); + + // The version stands in for the group in the library it lives in, because its primary is in + // a library of its own; a group merged inside this library still collapses onto its primary. + Assert.Contains(result, i => i.Id.Equals(versionId)); + Assert.Contains(result, i => i.Id.Equals(sameLibraryPrimaryId)); + Assert.DoesNotContain(result, i => i.Id.Equals(sameLibraryVersionId)); + Assert.DoesNotContain(result, i => i.Id.Equals(primaryId)); + } + + [Fact] + public void GetItemList_LibraryHoldingThePrimary_ReturnsThePrimary() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + SeedCrossLibraryGroup(primaryId, versionId); + + var result = _repository.GetItemList(CreateLibraryQuery(_movie4KLibraryId)); + + var item = Assert.Single(result); + Assert.Equal(primaryId, item.Id); + } + + [Fact] + public void GetItemList_BothLibrariesOfACrossLibraryGroup_ReturnsItOnce() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + SeedCrossLibraryGroup(primaryId, versionId); + + var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId, _movie4KLibraryId)); + + // With both libraries in scope the presentation key grouping collapses the version. + var item = Assert.Single(result); + Assert.Equal(primaryId, item.Id); + } + + [Fact] + public void GetItems_LibraryWithoutThePrimaryOfTheGroup_CountsWhatItLists() + { + var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + SeedCrossLibraryGroup(primaryId, versionId); + + var listed = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId)).Count; + + var query = CreateLibraryQuery(_movieLibraryId); + query.EnableTotalRecordCount = true; + query.Limit = 1; + + // The total the client pages against has to agree with the listing. + Assert.Equal(1, listed); + Assert.Equal(listed, _repository.GetItems(query).TotalRecordCount); + } + + private static InternalItemsQuery CreateLibraryQuery(params Guid[] topParentIds) + { + return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie], + TopParentIds = topParentIds + }; + } + + private void SeedCrossLibraryGroup( + Guid primaryId, + Guid versionId, + Guid? sameLibraryPrimaryId = null, + Guid? sameLibraryVersionId = null) + { + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(CreateFolderEntity(_movieLibraryId, "Movies")); + ctx.BaseItems.Add(CreateFolderEntity(_movie4KLibraryId, "Movies-4K")); + + // The 4K version heads the group and lives in a library of its own. + ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie - 4K", primaryId.ToString("N"), null, _movie4KLibraryId)); + ctx.BaseItems.Add(CreateMovieEntity(versionId, "Movie", primaryId.ToString("N"), primaryId, _movieLibraryId)); + + if (sameLibraryPrimaryId.HasValue && sameLibraryVersionId.HasValue) + { + ctx.BaseItems.Add(CreateMovieEntity(sameLibraryPrimaryId.Value, "Other - 4K", sameLibraryPrimaryId.Value.ToString("N"), null, _movieLibraryId)); + ctx.BaseItems.Add(CreateMovieEntity(sameLibraryVersionId.Value, "Other", sameLibraryPrimaryId.Value.ToString("N"), sameLibraryPrimaryId.Value, _movieLibraryId)); + } + + ctx.SaveChanges(); + } + + private BaseItemEntity CreateFolderEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = _folderTypeName, + Name = name, + Path = "/" + name, + IsFolder = true + }; + } + private static InternalItemsQuery CreateQuery() { // IncludeOwnedItems keeps the alternate version rows in the query so the @@ -117,13 +195,15 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable }; } - private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId) + private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId, Guid? libraryId = null) { return new BaseItemEntity { Id = id, Type = _movieTypeName, Name = name, + ParentId = libraryId, + TopParentId = libraryId, PresentationUniqueKey = presentationKey, PrimaryVersionId = primaryVersionId, MediaType = "Video", @@ -132,13 +212,4 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable IsVirtualItem = false }; } - - private JellyfinDbContext CreateDbContext() - { - return new JellyfinDbContext( - _dbOptions, - NullLogger<JellyfinDbContext>.Instance, - new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), - new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); - } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs new file mode 100644 index 0000000000..039693c432 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs @@ -0,0 +1,218 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture +{ + private readonly BaseItemRepository _repository; + private readonly string _audioTypeName; + private readonly string _movieTypeName; + + public BaseItemRepositoryItemValueTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _audioTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + _repository = CreateBaseItemRepository(itemTypeLookup); + } + + [Fact] + public void GetQueryFiltersLegacy_GroupsAndFiltersItemValues() + { + var firstItem = CreateMovieEntity(Guid.NewGuid(), "First"); + var secondItem = CreateMovieEntity(Guid.NewGuid(), "Second"); + var excludedItem = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Excluded Audio", + MediaType = "Audio", + IsMovie = false, + IsFolder = false, + IsVirtualItem = false + }; + var firstTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Alpha", + CleanValue = "alpha" + }; + var duplicateTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "alpha", + CleanValue = "alpha" + }; + var secondTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Beta", + CleanValue = "beta" + }; + var genre = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = "Genre Leak", + CleanValue = "genre leak" + }; + var excludedTag = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Tags, + Value = "Excluded Tag", + CleanValue = "excluded tag" + }; + var excludedGenre = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = "Excluded Genre", + CleanValue = "excluded genre" + }; + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(firstItem, secondItem, excludedItem); + context.ItemValues.AddRange(firstTag, duplicateTag, secondTag, genre, excludedTag, excludedGenre); + context.ItemValuesMap.AddRange( + CreateMap(firstItem, firstTag), + CreateMap(firstItem, duplicateTag), + CreateMap(secondItem, secondTag), + CreateMap(firstItem, genre), + CreateMap(excludedItem, excludedTag), + CreateMap(excludedItem, excludedGenre)); + context.SaveChanges(); + } + + var result = _repository.GetQueryFiltersLegacy(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal(["Alpha", "Beta"], result.Tags); + Assert.Equal(["Genre Leak"], result.Genres); + } + + [Fact] + public void GetTagNames_GroupsAndFiltersItemValues() + { + var movie = CreateMovieEntity(Guid.NewGuid(), "Movie"); + var otherMovie = CreateMovieEntity(Guid.NewGuid(), "Other Movie"); + var audio = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Excluded Audio", + MediaType = "Audio", + IsMovie = false, + IsFolder = false, + IsVirtualItem = false + }; + var tag = CreateItemValue(ItemValueType.Tags, "Alpha", "alpha"); + var duplicateTag = CreateItemValue(ItemValueType.Tags, "alpha", "alpha"); + var otherTag = CreateItemValue(ItemValueType.Tags, "Beta", "beta"); + var inheritedTag = CreateItemValue(ItemValueType.InheritedTags, "Inherited", "inherited"); + var genre = CreateItemValue(ItemValueType.Genre, "Genre Leak", "genre leak"); + var excludedTag = CreateItemValue(ItemValueType.Tags, "Excluded Tag", "excluded tag"); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(movie, otherMovie, audio); + context.ItemValues.AddRange(tag, duplicateTag, otherTag, inheritedTag, genre, excludedTag); + context.ItemValuesMap.AddRange( + CreateMap(movie, tag), + CreateMap(movie, duplicateTag), + CreateMap(otherMovie, otherTag), + CreateMap(movie, inheritedTag), + CreateMap(movie, genre), + CreateMap(audio, excludedTag)); + context.SaveChanges(); + } + + var result = _repository.GetTagNames(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie] + }); + + Assert.Equal(["Alpha", "Beta"], result); + } + + [Fact] + public void GetGenreNames_GroupsAndFiltersMappedItemValues() + { + var movie = CreateMovieEntity(Guid.NewGuid(), "Movie"); + var audio = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = _audioTypeName, + Name = "Audio", + MediaType = "Audio", + IsFolder = false, + IsVirtualItem = false + }; + var movieGenre = CreateItemValue(ItemValueType.Genre, "Movie Genre", "movie genre"); + var duplicateMovieGenre = CreateItemValue(ItemValueType.Genre, "movie genre", "movie genre"); + var musicGenre = CreateItemValue(ItemValueType.Genre, "Music Genre", "music genre"); + var orphanedGenre = CreateItemValue(ItemValueType.Genre, "Orphaned Genre", "orphaned genre"); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange(movie, audio); + context.ItemValues.AddRange(movieGenre, duplicateMovieGenre, musicGenre, orphanedGenre); + context.ItemValuesMap.AddRange( + CreateMap(movie, movieGenre), + CreateMap(movie, duplicateMovieGenre), + CreateMap(audio, musicGenre)); + context.SaveChanges(); + } + + Assert.Equal(["Movie Genre"], _repository.GetGenreNames()); + Assert.Equal(["Music Genre"], _repository.GetMusicGenreNames()); + } + + private BaseItemEntity CreateMovieEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue) + { + return new ItemValueMap + { + ItemId = item.Id, + ItemValueId = itemValue.ItemValueId, + Item = item, + ItemValue = itemValue + }; + } + + private static ItemValue CreateItemValue(ItemValueType type, string value, string cleanValue) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = value, + CleanValue = cleanValue + }; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs new file mode 100644 index 0000000000..c2518f13b2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; +using ItemSortBy = Jellyfin.Data.Enums.ItemSortBy; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers ordering by <see cref="ItemSortBy.IsPlayed"/> and <see cref="ItemSortBy.IsUnplayed"/>, which +/// has to read the played state the isPlayed filter reports: folders hold none of their own and count +/// as played once no descendant is left unplayed. +/// </summary> +public sealed class BaseItemRepositoryPlayedOrderingTests : SqliteDbTestFixture +{ + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + // Names run A..F so name order interleaves the two groups: a dropped or inverted played key shows + // up as a different sequence rather than as the expected one by luck. + private readonly Guid _watchedSeries = Guid.NewGuid(); + private readonly Guid _unwatchedSeries = Guid.NewGuid(); + private readonly Guid _partiallyWatchedSeries = Guid.NewGuid(); + private readonly Guid _secondUnwatchedSeries = Guid.NewGuid(); + private readonly Guid _thirdUnwatchedSeries = Guid.NewGuid(); + private readonly Guid _secondWatchedSeries = Guid.NewGuid(); + + // Box sets reach their children through LinkedChildren instead of the ancestor chain. + private readonly Guid _watchedBoxSet = Guid.NewGuid(); + private readonly Guid _unwatchedBoxSet = Guid.NewGuid(); + + private readonly HashSet<Guid> _unwatchedSeriesIds; + + public BaseItemRepositoryPlayedOrderingTests() + { + _unwatchedSeriesIds = [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries]; + + using (var context = CreateDbContext()) + { + Seed(context); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void IsPlayed_OrdersUnwatchedSeriesBeforeWatchedOnes() + { + Assert.Equal( + [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries, _watchedSeries, _secondWatchedSeries], + SeriesIds(ItemSortBy.IsPlayed)); + } + + [Fact] + public void IsPlayed_CountsAPartiallyWatchedSeriesAsUnwatched() + { + var ids = SeriesIds(ItemSortBy.IsPlayed); + + Assert.True(ids.IndexOf(_partiallyWatchedSeries) < ids.IndexOf(_watchedSeries)); + } + + [Fact] + public void IsUnplayed_ReversesTheGroups() + { + Assert.Equal( + [_watchedSeries, _secondWatchedSeries, _unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries], + SeriesIds(ItemSortBy.IsUnplayed)); + } + + [Fact] + public void IsPlayed_OrdersAnUnwatchedBoxSetBeforeAWatchedOne() + { + var ids = _repository + .GetItemList(Query(BaseItemKind.BoxSet, (ItemSortBy.IsPlayed, SortOrder.Ascending))) + .Select(i => i.Id); + + Assert.Equal([_unwatchedBoxSet, _watchedBoxSet], ids); + } + + [Fact] + public void IsPlayedThenRandom_StillPlacesEveryUnwatchedSeriesFirst() + { + var order = _repository + .GetItemList(Query(BaseItemKind.Series, (ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending))) + .Select(i => i.Id); + + Assert.Equal(_unwatchedSeriesIds, order.Take(_unwatchedSeriesIds.Count).ToHashSet()); + } + + [Fact] + public void IsPlayedThenRandom_FillsAPageWithUnwatchedSeries() + { + var page = _repository.GetItems(new InternalItemsQuery(_user) + { + IncludeItemTypes = [BaseItemKind.Series], + OrderBy = [(ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending)], + Limit = 4, + EnableTotalRecordCount = true + }); + + Assert.Equal(6, page.TotalRecordCount); + Assert.Equal(_unwatchedSeriesIds, page.Items.Select(i => i.Id).ToHashSet()); + } + + private List<Guid> SeriesIds(ItemSortBy sortBy) + => _repository + .GetItemList(Query(BaseItemKind.Series, (sortBy, SortOrder.Ascending))) + .Select(i => i.Id) + .ToList(); + + private InternalItemsQuery Query(BaseItemKind kind, params (ItemSortBy OrderBy, SortOrder SortOrder)[] orderBy) + => new(_user) + { + IncludeItemTypes = [kind], + OrderBy = orderBy + }; + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + AddSeries(context, _watchedSeries, "A watched", playedEpisodes: 1, unplayedEpisodes: 0); + AddSeries(context, _unwatchedSeries, "B unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _partiallyWatchedSeries, "C partially watched", playedEpisodes: 1, unplayedEpisodes: 1); + AddSeries(context, _secondUnwatchedSeries, "D unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _thirdUnwatchedSeries, "E unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _secondWatchedSeries, "F watched", playedEpisodes: 1, unplayedEpisodes: 0); + + AddBoxSet(context, _watchedBoxSet, "A watched set", played: true); + AddBoxSet(context, _unwatchedBoxSet, "B unwatched set", played: false); + + context.SaveChanges(); + } + + private void AddSeries(JellyfinDbContext context, Guid id, string name, int playedEpisodes, int unplayedEpisodes) + { + context.BaseItems.Add(new BaseItemEntity { Id = id, Type = SeriesType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true }); + + for (var i = 0; i < playedEpisodes + unplayedEpisodes; i++) + { + var episodeId = Guid.NewGuid(); + context.BaseItems.Add(new BaseItemEntity { Id = episodeId, Type = EpisodeType, Name = $"{name} {i}", PresentationUniqueKey = episodeId.ToString("N"), SeriesId = id }); + context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = id, Item = null!, ParentItem = null! }); + + if (i < playedEpisodes) + { + AddPlayedUserData(context, episodeId); + } + } + } + + private void AddBoxSet(JellyfinDbContext context, Guid id, string name, bool played) + { + var movieId = Guid.NewGuid(); + + context.BaseItems.Add(new BaseItemEntity { Id = id, Type = BoxSetType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = MovieType, Name = $"{name} movie", PresentationUniqueKey = movieId.ToString("N") }); + context.LinkedChildren.Add(new LinkedChildEntity { ParentId = id, ChildId = movieId, ChildType = LinkedChildType.Manual, SortOrder = 0 }); + + if (played) + { + AddPlayedUserData(context, movieId); + } + } + + private void AddPlayedUserData(JellyfinDbContext context, Guid itemId) + => context.UserData.Add(new UserData + { + ItemId = itemId, + UserId = _user.Id, + CustomDataKey = itemId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs new file mode 100644 index 0000000000..a9548a6d13 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the isPlayed filter over items with alternate versions: playback is recorded against the +/// version that was actually played, so the played state belongs to the version group rather than to +/// the row that happens to carry it. +/// </summary> +public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture +{ + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private readonly BaseItemRepository _repository; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + private readonly Guid _playedViaAlternate = Guid.NewGuid(); + private readonly Guid _playedOnPrimary = Guid.NewGuid(); + private readonly Guid _unplayedWithAlternate = Guid.NewGuid(); + private readonly Guid _unplayedWithoutAlternate = Guid.NewGuid(); + + private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid(); + private readonly Guid _unplayedSeries = Guid.NewGuid(); + private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid(); + private readonly Guid _partiallyPlayedSeries = Guid.NewGuid(); + + public BaseItemRepositoryPlayedVersionTests() + { + using (var context = CreateDbContext()) + { + Seed(context); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void IsPlayed_CountsAMoviePlayedThroughItsAlternateVersion() + { + Assert.Equal( + new HashSet<Guid> { _playedOnPrimary, _playedViaAlternate }, + Ids(BaseItemKind.Movie, isPlayed: true)); + } + + [Fact] + public void IsUnplayed_DropsAMoviePlayedThroughItsAlternateVersion() + { + Assert.Equal( + new HashSet<Guid> { _unplayedWithAlternate, _unplayedWithoutAlternate }, + Ids(BaseItemKind.Movie, isPlayed: false)); + } + + [Fact] + public void IsPlayed_KeepsAPlayedPrimaryWhoseAlternateHasNoRowOfItsOwn() + { + Assert.Contains(_playedOnPrimary, Ids(BaseItemKind.Movie, isPlayed: true)); + } + + [Fact] + public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() + { + Assert.Equal( + new HashSet<Guid> { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions }, + Ids(BaseItemKind.Series, isPlayed: true)); + Assert.Equal( + new HashSet<Guid> { _unplayedSeries, _partiallyPlayedSeries }, + Ids(BaseItemKind.Series, isPlayed: false)); + } + + [Fact] + public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() + { + Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true)); + Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true)); + } + + [Fact] + public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion() + { + var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true }); + + // Nothing is left to watch, so the series is not half finished. + Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable); + Assert.Contains(_partiallyPlayedSeries, resumable); + } + + private HashSet<Guid> Ids(BaseItemKind kind, bool isPlayed) + => _repository + .GetItemList(new InternalItemsQuery(_user) + { + IncludeItemTypes = [kind], + IsPlayed = isPlayed + }) + .Select(i => i.Id) + .ToHashSet(); + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + // Only the alternate carries the played row, which is what playing that version records. + AddMovieWithAlternate(context, _playedViaAlternate, "A", playedPrimary: false, playedAlternate: true); + AddMovieWithAlternate(context, _playedOnPrimary, "B", playedPrimary: true, playedAlternate: false); + AddMovieWithAlternate(context, _unplayedWithAlternate, "C", playedPrimary: false, playedAlternate: false); + AddItem(context, _unplayedWithoutAlternate, MovieType, "D"); + + AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true); + AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false); + + AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true); + AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false); + + context.SaveChanges(); + } + + private void AddMovieWithAlternate(JellyfinDbContext context, Guid primaryId, string name, bool playedPrimary, bool playedAlternate) + { + AddItem(context, primaryId, MovieType, name); + AddAlternateVersion(context, primaryId, MovieType, $"{name} 4K", playedAlternate); + + if (playedPrimary) + { + AddPlayedUserData(context, primaryId); + } + } + + private void AddSeriesWithAlternateEpisode(JellyfinDbContext context, Guid seriesId, string name, bool playedAlternate) + { + var episodeId = Guid.NewGuid(); + + AddSeriesFolder(context, seriesId, name); + + AddItem(context, episodeId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + + AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate); + } + + // A watched first episode plus a second one that is either watched as its alternate version or not + // watched at all, which is what separates a finished series from a half watched one. + private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate) + { + AddSeriesFolder(context, seriesId, name); + + var firstId = Guid.NewGuid(); + AddItem(context, firstId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddPlayedUserData(context, firstId); + + var secondId = Guid.NewGuid(); + AddItem(context, secondId, EpisodeType, $"{name} 2"); + context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate); + } + + private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name) + => context.BaseItems.Add(new BaseItemEntity + { + Id = seriesId, + Type = SeriesType, + Name = name, + SortName = name, + PresentationUniqueKey = seriesId.ToString("N"), + IsFolder = true + }); + + private void AddItem(JellyfinDbContext context, Guid id, string type, string name) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = type, + Name = name, + SortName = name, + PresentationUniqueKey = id.ToString("N") + }); + + private void AddAlternateVersion(JellyfinDbContext context, Guid primaryId, string type, string name, bool played) + { + var alternateId = Guid.NewGuid(); + + // An alternate presents under its primary's key, which is what collapses the group in listings. + context.BaseItems.Add(new BaseItemEntity + { + Id = alternateId, + Type = type, + Name = name, + SortName = name, + PresentationUniqueKey = primaryId.ToString("N"), + PrimaryVersionId = primaryId + }); + + if (played) + { + AddPlayedUserData(context, alternateId); + } + } + + private void AddPlayedUserData(JellyfinDbContext context, Guid itemId) + => context.UserData.Add(new UserData + { + ItemId = itemId, + UserId = _user.Id, + CustomDataKey = itemId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs new file mode 100644 index 0000000000..4e8d84850b --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs @@ -0,0 +1,553 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the filters resolving "folders with a matching descendant" through +/// <see cref="DescendantQueryHelper.GetFolderIdsMatching"/>, positive and negated. +/// </summary> +public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _withSubtitles = Guid.NewGuid(); + private readonly Guid _withoutSubtitles = Guid.NewGuid(); + private readonly Guid _collection = Guid.NewGuid(); + private readonly Guid _linkedSeries = Guid.NewGuid(); + private readonly Guid _linkedEpisode = Guid.NewGuid(); + + // A version group in a library of its own, so it cannot move the assertions above: an SD primary + // that carries nothing, and a 4K second file carrying the subtitles, chapter image and audio. + private readonly Guid _versionLibrary = Guid.NewGuid(); + private readonly Guid _versionedMovie = Guid.NewGuid(); + private readonly Guid _alternateVersion = Guid.NewGuid(); + + // A series in the same library, so the folder branch of the resolution filter has a version group + // to reach through as well: an SD episode whose second file is 4K. + private readonly Guid _versionedSeries = Guid.NewGuid(); + private readonly Guid _versionedEpisode = Guid.NewGuid(); + private readonly Guid _episodeAlternate = Guid.NewGuid(); + + // An unprobed primary: only its second file carries dimensions, and they are SD. + private readonly Guid _unprobedMovie = Guid.NewGuid(); + private readonly Guid _unprobedAlternate = Guid.NewGuid(); + + // A plain SD movie with no second file, as the control the version groups are read against. + private readonly Guid _sdMovie = Guid.NewGuid(); + + // An unprobed primary whose only second file is HD, so the HD bucket has to place it off nulls. + private readonly Guid _hdOnlyByVersion = Guid.NewGuid(); + private readonly Guid _hdOnlyAlternate = Guid.NewGuid(); + + // Three files for one movie: the HD one would place it in the HD bucket on its own, the 4K one has + // to win. Only a group holding both can tell the HD bucket's upper guard from its lower one. + private readonly Guid _threeWayMovie = Guid.NewGuid(); + private readonly Guid _threeWayHd = Guid.NewGuid(); + private readonly Guid _threeWay4K = Guid.NewGuid(); + + public BaseItemRepositoryStreamFilterTests() + { + using (var ctx = CreateDbContext()) + { + Seed(ctx); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void HasSubtitles_MatchesTheItemAndItsParentFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true }); + + Assert.Contains(_withSubtitles, ids); + // The library is a folder, and it has a descendant with subtitles. + Assert.Contains(_library, ids); + Assert.DoesNotContain(_withoutSubtitles, ids); + } + + [Fact] + public void HasSubtitles_Negated_ExcludesTheItemAndItsParentFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false }); + + Assert.Contains(_withoutSubtitles, ids); + Assert.DoesNotContain(_withSubtitles, ids); + Assert.DoesNotContain(_library, ids); + } + + [Fact] + public void SubtitleLanguages_MatchesTheRequestedLanguageOnly() + { + Assert.Contains(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] })); + Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] })); + } + + [Fact] + public void HasNoSubtitleTrackWithLanguage_ExcludesTheMatchingItemAndFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" }); + + Assert.Contains(_withoutSubtitles, ids); + Assert.DoesNotContain(_withSubtitles, ids); + Assert.DoesNotContain(_library, ids); + } + + [Fact] + public void HasSubtitles_MatchesACollectionLinkingAFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true }); + + Assert.Contains(_linkedSeries, ids); + Assert.Contains(_collection, ids); + } + + [Fact] + public void HasSubtitles_Negated_ExcludesACollectionLinkingAFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false }); + + Assert.DoesNotContain(_linkedSeries, ids); + Assert.DoesNotContain(_collection, ids); + } + + [Fact] + public void HasChapterImages_MatchesTheItemAndItsParentFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true }); + + Assert.Contains(_withSubtitles, ids); + Assert.Contains(_library, ids); + Assert.DoesNotContain(_withoutSubtitles, ids); + } + + [Fact] + public void HasSubtitles_MatchesAnItemWhoseAlternateVersionCarriesThem() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true }); + + Assert.Contains(_versionedMovie, ids); + Assert.Contains(_versionLibrary, ids); + // The second file is never listed on its own, which is why its tracks have to count for the primary. + Assert.DoesNotContain(_alternateVersion, ids); + } + + [Fact] + public void HasSubtitles_Negated_ExcludesAnItemWhoseAlternateVersionCarriesThem() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false }); + + Assert.DoesNotContain(_versionedMovie, ids); + Assert.DoesNotContain(_versionLibrary, ids); + } + + [Fact] + public void SubtitleLanguages_MatchesTheLanguageOnAnAlternateVersion() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] })); + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] })); + } + + [Fact] + public void HasNoSubtitleTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" }); + + Assert.DoesNotContain(_versionedMovie, ids); + Assert.DoesNotContain(_versionLibrary, ids); + } + + [Fact] + public void AudioLanguages_MatchesTheLanguageOnAnAlternateVersion() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { AudioLanguages = ["fre"] })); + } + + [Fact] + public void HasNoAudioTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt() + { + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = "fre" })); + } + + [Fact] + public void HasChapterImages_MatchesAnItemWhoseAlternateVersionCarriesThem() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true })); + } + + [Fact] + public void Is4K_MatchesAnItemWhoseAlternateVersionIs4K() + { + // The primary file is SD; the resolution a caller can actually play is the 4K second file's. + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true })); + } + + [Fact] + public void MinWidth_MatchesAnItemWhoseAlternateVersionIsWideEnough() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 })); + Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 })); + } + + [Fact] + public void MaxWidth_ExcludesAnItemWhoseAlternateVersionBreachesTheBound() + { + // The SD primary is narrow enough on its own, but the 4K second file is what a caller would play. + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 })); + Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 })); + } + + [Fact] + public void MaxHeight_ExcludesAnItemWhoseAlternateVersionBreachesTheBound() + { + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 })); + Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 })); + } + + [Fact] + public void IsHD_False_ExcludesAnSdPrimaryWhoseAlternateVersionIsBetter() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }); + + // 720x480 on its own, but the version group tops out at 4K. + Assert.DoesNotContain(_versionedMovie, ids); + Assert.Contains(_sdMovie, ids); + } + + [Fact] + public void IsHD_False_MatchesAPrimaryPlacedOnlyByItsAlternateVersion() + { + // The primary carries no dimensions at all; the SD second file is the group's best. + Assert.Contains(_unprobedMovie, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false })); + } + + [Fact] + public void IsHD_True_ExcludesAnItemWhoseVersionGroupReaches4K() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true }); + + Assert.DoesNotContain(_versionedMovie, ids); + Assert.DoesNotContain(_unprobedMovie, ids); + // The 1920-wide second file alone would say HD; the 4K third file is the group's best. + Assert.DoesNotContain(_threeWayMovie, ids); + } + + [Fact] + public void Is4K_MatchesAnItemWhoseVersionGroupHoldsBothHdAnd4K() + { + Assert.Contains(_threeWayMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true })); + } + + [Fact] + public void IsHD_True_MatchesAPrimaryPlacedOnlyByItsAlternateVersion() + { + // The primary carries no dimensions of its own; the HD second file is the group's best. + Assert.Contains(_hdOnlyByVersion, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true })); + } + + [Fact] + public void Is4K_MatchesTheSeriesOfAnEpisodeWhoseAlternateVersionIs4K() + { + // The folder branch buckets a descendant the same way the item branch buckets a top-level item. + Assert.Contains(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true })); + } + + [Fact] + public void IsHD_False_ExcludesTheSeriesOfAnSdEpisodeWithABetterAlternateVersion() + { + // Before the version group was consulted on descendants too, the SD episode alone matched here + // while the same pair at top level did not. + Assert.DoesNotContain(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false })); + } + + [Theory] + [InlineData("und")] + [InlineData("UND")] + public void HasNoAudioTrackWithLanguage_TreatsUndeterminedCaseInsensitively(string language) + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = language }); + + // The alternate version carries an audio track with no language, which is what "und" stands for, + // so the item it is reported against does have one. + Assert.DoesNotContain(_unprobedMovie, ids); + Assert.Contains(_versionedMovie, ids); + } + + private void Seed(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _withSubtitles, Type = MovieType, Name = "With subtitles" }); + context.BaseItems.Add(new BaseItemEntity { Id = _withoutSubtitles, Type = MovieType, Name = "Without subtitles" }); + + foreach (var itemId in new[] { _withSubtitles, _withoutSubtitles }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _library, + Item = null!, + ParentItem = null! + }); + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = itemId, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Video, + Item = null! + }); + } + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _withSubtitles, + StreamIndex = 1, + StreamType = MediaStreamTypeEntity.Subtitle, + Language = "ger", + Item = null! + }); + + // A collection linking a folder: the match is two edges away, one link then one closure hop. + context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _linkedSeries, Type = FolderType, Name = "Linked series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _linkedEpisode, Type = MovieType, Name = "Linked episode" }); + + context.AncestorIds.Add(new AncestorId + { + ItemId = _linkedEpisode, + ParentItemId = _linkedSeries, + Item = null!, + ParentItem = null! + }); + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _collection, + ChildId = _linkedSeries, + ChildType = LinkedChildType.Manual, + SortOrder = 0 + }); + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _linkedEpisode, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Subtitle, + Language = "ger", + Item = null! + }); + + context.Chapters.Add(new Chapter + { + ItemId = _withSubtitles, + ChapterIndex = 0, + StartPositionTicks = 0, + ImagePath = "/chapter.jpg", + Item = null! + }); + + SeedVersionGroup(context); + + context.SaveChanges(); + } + + // An SD primary whose only extras live on a 4K second file, so every filter has to reach through + // PrimaryVersionId to answer correctly. + private void SeedVersionGroup(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _versionLibrary, Type = FolderType, Name = "Version library", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _versionedMovie, Type = MovieType, Name = "Versioned movie", Width = 720, Height = 480 }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _alternateVersion, + Type = MovieType, + Name = "Versioned movie 4K", + PrimaryVersionId = _versionedMovie, + Width = 3840, + Height = 2160 + }); + + foreach (var itemId in new[] { _versionedMovie, _alternateVersion }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + } + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _alternateVersion, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Subtitle, + Language = "ger", + Item = null! + }); + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _alternateVersion, + StreamIndex = 1, + StreamType = MediaStreamTypeEntity.Audio, + Language = "fre", + Item = null! + }); + + SeedVersionedSeries(context); + SeedUnprobedVersionGroup(context); + + context.Chapters.Add(new Chapter + { + ItemId = _alternateVersion, + ChapterIndex = 0, + StartPositionTicks = 0, + ImagePath = "/alternate-chapter.jpg", + Item = null! + }); + } + + // The same SD primary / 4K second file pair one level down, so the resolution filter has to answer + // for the series off its descendants. + private void SeedVersionedSeries(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _versionedSeries, Type = FolderType, Name = "Versioned series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _versionedEpisode, Type = MovieType, Name = "Versioned episode", Width = 720, Height = 480 }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _episodeAlternate, + Type = MovieType, + Name = "Versioned episode 4K", + PrimaryVersionId = _versionedEpisode, + Width = 3840, + Height = 2160 + }); + + context.AncestorIds.Add(new AncestorId + { + ItemId = _versionedSeries, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + + foreach (var itemId in new[] { _versionedEpisode, _episodeAlternate }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionedSeries, + Item = null!, + ParentItem = null! + }); + } + } + + // A primary that was never probed, so only its second file can place it in a bucket. Its audio track + // declares no language, which is what the "und" filters stand in for. + private void SeedUnprobedVersionGroup(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _sdMovie, Type = MovieType, Name = "SD movie", Width = 720, Height = 480 }); + context.AncestorIds.Add(new AncestorId + { + ItemId = _sdMovie, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + + context.BaseItems.Add(new BaseItemEntity { Id = _unprobedMovie, Type = MovieType, Name = "Unprobed movie" }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _unprobedAlternate, + Type = MovieType, + Name = "Unprobed movie SD", + PrimaryVersionId = _unprobedMovie, + Width = 720, + Height = 480 + }); + + foreach (var itemId in new[] { _unprobedMovie, _unprobedAlternate }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + } + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _unprobedAlternate, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Audio, + Item = null! + }); + + SeedMixedVersionGroups(context); + } + + // The two groups that separate the HD bucket's lower bound from its upper one: one that only a 4K + // third file keeps out of HD, and one that only an HD second file puts into it. + private void SeedMixedVersionGroups(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _threeWayMovie, Type = MovieType, Name = "Three-way movie", Width = 720, Height = 480 }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _threeWayHd, + Type = MovieType, + Name = "Three-way movie HD", + PrimaryVersionId = _threeWayMovie, + Width = 1920, + Height = 1080 + }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _threeWay4K, + Type = MovieType, + Name = "Three-way movie 4K", + PrimaryVersionId = _threeWayMovie, + Width = 3840, + Height = 2160 + }); + + context.BaseItems.Add(new BaseItemEntity { Id = _hdOnlyByVersion, Type = MovieType, Name = "HD only by version" }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _hdOnlyAlternate, + Type = MovieType, + Name = "HD only by version, HD file", + PrimaryVersionId = _hdOnlyByVersion, + Width = 1920, + Height = 1080 + }); + + foreach (var itemId in new[] { _threeWayMovie, _threeWayHd, _threeWay4K, _hdOnlyByVersion, _hdOnlyAlternate }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs new file mode 100644 index 0000000000..f2ecfadd50 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs @@ -0,0 +1,526 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.MatchCriteria; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that +/// they stay sub-selects instead of inlining every descendant id into the statement. +/// </summary> +public sealed class DescendantQueryHelperTests : SqliteDbTestFixture +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly Dictionary<Guid, int> _linkCounters = new(); + + public DescendantQueryHelperTests() + { + } + + [Fact] + public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent() + { + var library = Guid.NewGuid(); + var series = Guid.NewGuid(); + var season = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, library); + AddFolder(ctx, series); + AddFolder(ctx, season); + AddItem(ctx, episode, MovieType); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. + AddAncestors(ctx, series, library); + AddAncestors(ctx, season, series, library); + AddAncestors(ctx, episode, season, series, library); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet(); + + Assert.Equal(new[] { series, season, episode }.Order(), descendants.Order()); + Assert.DoesNotContain(library, descendants); + } + } + + [Fact] + public void GetAllDescendantIds_LinkedFolder_IncludesItsOwnDescendants() + { + var boxSet = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + AddAncestors(ctx, episode, series); + AddLink(ctx, boxSet, series); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet(); + + Assert.Contains(series, descendants); + Assert.Contains(episode, descendants); + } + } + + // Timeout so that a missing termination guard fails the test instead of hanging the run. + [Fact(Timeout = 30000)] + public void GetAllDescendantIds_NestedLinks_AreFollowedAndCyclesTerminate() + { + var outer = Guid.NewGuid(); + var inner = Guid.NewGuid(); + var movie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, outer, BoxSetType, isFolder: true); + AddItem(ctx, inner, BoxSetType, isFolder: true); + AddItem(ctx, movie, MovieType); + + AddLink(ctx, outer, inner); + AddLink(ctx, inner, movie); + // The traversal must not spin on this cycle. + AddLink(ctx, inner, outer); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, outer).ToHashSet(); + + Assert.Contains(inner, descendants); + Assert.Contains(movie, descendants); + Assert.DoesNotContain(outer, descendants); + } + } + + [Fact] + public void GetAllDescendantIds_LinksOfNonFolders_AreNotFollowed() + { + var library = Guid.NewGuid(); + var movie = Guid.NewGuid(); + var alternateVersion = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, library); + AddItem(ctx, movie, MovieType); + AddItem(ctx, alternateVersion, MovieType); + + AddAncestors(ctx, movie, library); + // An alternate version hangs off the movie by link, and the movie is not a folder. + AddLink(ctx, movie, alternateVersion); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet(); + + Assert.Contains(movie, descendants); + Assert.DoesNotContain(alternateVersion, descendants); + } + } + + [Fact] + public void GetAllDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed() + { + var userRoot = Guid.NewGuid(); + var collectionFolder = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var linkedMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, userRoot); + AddFolder(ctx, collectionFolder); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddItem(ctx, linkedMovie, MovieType); + + // An item carries its own chain plus its collection folder, but not the user root above + // it, so one hop from the user root stops at the collection folder. + AddAncestors(ctx, collectionFolder, userRoot); + AddAncestors(ctx, series, collectionFolder); + AddAncestors(ctx, episode, series, collectionFolder); + AddAncestors(ctx, boxSet, collectionFolder); + // The box set is only reachable across the seam, and its links have to be followed too. + AddLink(ctx, boxSet, linkedMovie); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, userRoot).ToHashSet(); + + Assert.Equal( + new[] { collectionFolder, series, episode, boxSet, linkedMovie }.Order(), + descendants.Order()); + } + } + + [Fact] + public void GetOwnedDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed() + { + var userRoot = Guid.NewGuid(); + var collectionFolder = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var linkedMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, userRoot); + AddFolder(ctx, collectionFolder); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddItem(ctx, linkedMovie, MovieType); + + AddAncestors(ctx, collectionFolder, userRoot); + AddAncestors(ctx, series, collectionFolder); + AddAncestors(ctx, episode, series, collectionFolder); + AddAncestors(ctx, boxSet, collectionFolder); + AddLink(ctx, boxSet, linkedMovie); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + // Owned only: the linked movie stays out, or deleting a library would delete it. + var expected = new[] { collectionFolder, series, episode, boxSet }.Order(); + + Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIds(ctx, userRoot).ToHashSet().Order()); + Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [userRoot]).Order()); + } + } + + [Fact] + public void GetFolderIdsMatching_LinkAboveAClosure_ReturnsTheLinkingFolder() + { + var collections = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var library = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + var otherLibrary = Guid.NewGuid(); + var otherBoxSet = Guid.NewGuid(); + var silentMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, collections); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, library); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + AddAncestors(ctx, boxSet, collections); + AddAncestors(ctx, series, library); + AddAncestors(ctx, episode, series, library); + // The link lands on the series, not on the episode that carries the subtitles. + AddLink(ctx, boxSet, series); + AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle); + + AddFolder(ctx, otherLibrary); + AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true); + AddItem(ctx, silentMovie, MovieType); + AddAncestors(ctx, otherBoxSet, collections); + AddAncestors(ctx, silentMovie, otherLibrary); + AddLink(ctx, otherBoxSet, silentMovie); + // A stream of another type: the criteria, not the mere presence of a stream, decides. + AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video); + + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + Assert.Equal(new[] { library, series, boxSet, collections }.Order(), folders.Order()); + } + } + + [Fact(Timeout = 30000)] + public void GetFolderIdsMatching_NestedLinks_AreFollowedAndCyclesTerminate() + { + var outer = Guid.NewGuid(); + var inner = Guid.NewGuid(); + var movie = Guid.NewGuid(); + var silentSet = Guid.NewGuid(); + var silentMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, outer, BoxSetType, isFolder: true); + AddItem(ctx, inner, BoxSetType, isFolder: true); + AddItem(ctx, movie, MovieType); + + AddLink(ctx, outer, inner); + AddLink(ctx, inner, movie); + // Resolving the link parents must not spin on this cycle. + AddLink(ctx, inner, outer); + AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle); + + AddItem(ctx, silentSet, BoxSetType, isFolder: true); + AddItem(ctx, silentMovie, MovieType); + AddLink(ctx, silentSet, silentMovie); + AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video); + + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + Assert.Equal(new[] { inner, outer }.Order(), folders.Order()); + } + } + + [Fact] + public void GetFolderIdsMatching_ClosureSeamAboveTheCollectionFolder_IsCrossed() + { + var userRoot = Guid.NewGuid(); + var collectionFolder = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, userRoot); + AddFolder(ctx, collectionFolder); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + // The closure is not transitive at this seam: no item records the user root. + AddAncestors(ctx, episode, series, collectionFolder); + AddAncestors(ctx, series, collectionFolder); + AddAncestors(ctx, collectionFolder, userRoot); + AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + Assert.Equal(new[] { series, collectionFolder, userRoot }.Order(), folders.Order()); + } + } + + [Fact] + public void GetFolderIdsMatching_LinkedFolder_MatchesOnLanguageOnly() + { + var boxSet = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + AddAncestors(ctx, episode, series); + AddLink(ctx, boxSet, series); + AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle, "ger"); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var german = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["ger"]); + var french = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["fre"]); + + Assert.Equal(new[] { series, boxSet }.Order(), DescendantQueryHelper.GetFolderIdsMatching(ctx, german).ToHashSet().Order()); + Assert.Empty(DescendantQueryHelper.GetFolderIdsMatching(ctx, french).ToArray()); + } + } + + [Fact] + public void GetFolderIdsMatching_AlternateVersionLinks_AreNotWalked() + { + var collections = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var library = Guid.NewGuid(); + var movie = Guid.NewGuid(); + var alternateVersion = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, collections); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, library); + AddItem(ctx, movie, MovieType); + AddItem(ctx, alternateVersion, MovieType); + + AddAncestors(ctx, boxSet, collections); + AddAncestors(ctx, movie, library); + AddAncestors(ctx, alternateVersion, library); + // Only the second file carries the subtitles, and it hangs off the movie by an alternate + // version link. The movie is not a folder, so that link is not a parent-child edge. + AddLink(ctx, movie, alternateVersion, LinkedChildType.LocalAlternateVersion); + AddLink(ctx, boxSet, movie); + AddStream(ctx, alternateVersion, MediaStreamTypeEntity.Subtitle); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + // The library still matches: the alternate version carries its own closure. The box set does + // not, matching the descendant side, which does not follow a non-folder's links either. + Assert.Equal([library], folders); + } + } + + [Fact] + public void GetOwnedDescendantIds_IgnoresLinkedChildren() + { + var boxSet = Guid.NewGuid(); + var owned = Guid.NewGuid(); + var linked = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddItem(ctx, owned, MovieType); + AddItem(ctx, linked, MovieType); + + AddAncestors(ctx, owned, boxSet); + AddLink(ctx, boxSet, linked); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIds(ctx, boxSet).ToArray()); + Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [boxSet]).ToArray()); + } + } + + [Fact] + public void GetAllDescendantIds_StatementSizeDoesNotGrowWithTheLibrary() + { + var small = SeedLibrary(10); + var large = SeedLibrary(500); + + using var ctx = CreateDbContext(); + + var smallSql = CountingQuery(ctx, small).ToQueryString(); + var largeSql = CountingQuery(ctx, large).ToQueryString(); + + // Reading the ids into memory and handing them back as AsQueryable() makes EF inline one + // literal per descendant, which is what allocated megabytes per call. + Assert.Equal(smallSql.Length, largeSql.Length); + Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal); + Assert.Equal(10, CountingQuery(ctx, small).Count()); + Assert.Equal(500, CountingQuery(ctx, large).Count()); + } + + private static IQueryable<BaseItemEntity> CountingQuery(JellyfinDbContext context, Guid libraryId) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, libraryId); + + return context.BaseItems + .AsNoTracking() + .Where(b => descendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); + } + + private Guid SeedLibrary(int childCount) + { + var library = Guid.NewGuid(); + + using var ctx = CreateDbContext(); + AddFolder(ctx, library); + for (var i = 0; i < childCount; i++) + { + var child = Guid.NewGuid(); + AddItem(ctx, child, MovieType); + AddAncestors(ctx, child, library); + } + + ctx.SaveChanges(); + + return library; + } + + private static void AddFolder(JellyfinDbContext context, Guid id) + => AddItem(context, id, FolderType, isFolder: true); + + private static void AddItem(JellyfinDbContext context, Guid id, string type, bool isFolder = false) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = type, + Name = type + " " + id, + IsFolder = isFolder + }); + + private static void AddStream(JellyfinDbContext context, Guid itemId, MediaStreamTypeEntity type, string? language = null) + => context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = itemId, + StreamIndex = 0, + StreamType = type, + Language = language, + Item = null! + }); + + private static void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + // LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot. + private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId, LinkedChildType childType = LinkedChildType.Manual) + { + _linkCounters.TryGetValue(parentId, out var sortOrder); + _linkCounters[parentId] = sortOrder + 1; + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = childType, + SortOrder = sortOrder + }); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs new file mode 100644 index 0000000000..787bb24150 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -0,0 +1,1278 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ItemCountServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly IApplicationPaths _applicationPaths; + private readonly ItemCountService _service; + private int _contextsCreated; + private List<string>? _capturedSql; + + public ItemCountServiceTests() + { + _applicationPaths = new Mock<IApplicationPaths>().Object; + + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .LogTo(CaptureStatement, LogLevel.Information) + .Options; + + using (var context = CreateDbContext()) + { + context.Database.EnsureCreated(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(() => + { + _contextsCreated++; + return CreateDbContext(); + }); + + var queryHelpers = new Mock<IItemQueryHelpers>(); + queryHelpers + .Setup(h => h.ApplyAccessFiltering( + It.IsAny<JellyfinDbContext>(), + It.IsAny<IQueryable<BaseItemEntity>>(), + It.IsAny<InternalItemsQuery>())) + .Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query); + + var typeLookup = new Mock<IItemTypeLookup>(); + typeLookup.Setup(l => l.BaseItemKindNames).Returns(new Dictionary<BaseItemKind, string> + { + [BaseItemKind.Movie] = "Movie", + [BaseItemKind.Series] = "Series", + [BaseItemKind.Episode] = "Episode", + [BaseItemKind.MusicAlbum] = "MusicAlbum", + [BaseItemKind.MusicArtist] = "MusicArtist", + [BaseItemKind.MusicVideo] = "MusicVideo", + [BaseItemKind.Audio] = "Audio", + [BaseItemKind.Trailer] = "Trailer", + [BaseItemKind.BoxSet] = "BoxSet", + [BaseItemKind.Book] = "Book", + [BaseItemKind.LiveTvProgram] = "LiveTvProgram" + }); + + _service = new ItemCountService( + factory.Object, + typeLookup.Object, + queryHelpers.Object); + } + + public void Dispose() + { + _connection.Dispose(); + } + + private void CaptureStatement(string message) + { + if (_capturedSql is not null && message.Contains("SELECT", StringComparison.Ordinal)) + { + _capturedSql.Add(message[message.IndexOf("SELECT", StringComparison.Ordinal)..]); + } + } + + [Fact] + public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit() + { + var hierarchicalParentId = Guid.NewGuid(); + var linkedParentId = Guid.NewGuid(); + + var hierarchicalChildId = Guid.NewGuid(); + var linkedChildId1 = Guid.NewGuid(); + var linkedChildId2 = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.AddRange( + CreateItem(hierarchicalParentId), + CreateItem(linkedParentId), + CreateItem(hierarchicalChildId, hierarchicalParentId), + CreateItem(linkedChildId1), + CreateItem(linkedChildId2)); + + context.LinkedChildren.AddRange( + new LinkedChildEntity + { + ParentId = linkedParentId, + ChildId = linkedChildId1, + ChildType = LinkedChildType.Manual, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = linkedParentId, + ChildId = linkedChildId2, + ChildType = LinkedChildType.Manual, + SortOrder = 1 + }); + + context.SaveChanges(); + } + + var parentIds = Enumerable.Range(0, 40_000) + .Select(_ => Guid.NewGuid()) + .ToList(); + + parentIds.Add(hierarchicalParentId); + parentIds.Add(linkedParentId); + + var result = _service.GetChildCountBatch(parentIds, null); + + Assert.Equal(1, result[hierarchicalParentId]); + Assert.Equal(2, result[linkedParentId]); + Assert.Equal(parentIds.Count, result.Count); + } + + [Fact] + public void GetCounts_MergedFolders_CountLeavesOfEveryFolderInTheGroup() + { + // Two folder-items of one merged series: same presentation key, a leaf each, one of them played. + var (user, seriesA, seriesB) = SeedMergedSeries(out var playedLeafId); + + var filter = new InternalItemsQuery(user); + + // Either folder-item stands for the whole merged series, so both must report the group. + foreach (var seriesId in new[] { seriesA, seriesB }) + { + Assert.Equal(2, _service.GetTotalCount(filter, seriesId)); + Assert.Equal(1, _service.GetPlayedCount(filter, seriesId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, seriesId)); + } + + var batch = _service.GetPlayedAndTotalCountBatch([seriesA], user); + Assert.Equal((1, 2), batch[seriesA]); + + Assert.NotEqual(Guid.Empty, playedLeafId); + } + + [Fact] + public void GetCounts_UnmergedFolder_CountsOnlyItsOwnLeaves() + { + var (user, _, _) = SeedMergedSeries(out _); + + // A folder with a key of its own must not pick up anything from the merged pair. + var loneSeriesId = Guid.NewGuid(); + var loneLeafId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + var lone = CreateItem(loneSeriesId); + lone.PresentationUniqueKey = "lone-series"; + context.BaseItems.Add(lone); + context.BaseItems.Add(CreateLeaf(loneLeafId)); + context.SaveChanges(); + AddAncestor(context, loneLeafId, loneSeriesId); + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + Assert.Equal(1, _service.GetTotalCount(filter, loneSeriesId)); + Assert.Equal(0, _service.GetPlayedCount(filter, loneSeriesId)); + Assert.Equal((0, 1), _service.GetPlayedAndTotalCount(filter, loneSeriesId)); + } + + [Fact] + public void GetCounts_PlayedAlternateVersion_CountThePrimaryAsPlayed() + { + var user = new User("alt-version-test", "provider", "reset"); + var seriesId = Guid.NewGuid(); + var primaryId = Guid.NewGuid(); + var alternateId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + var series = CreateItem(seriesId); + series.PresentationUniqueKey = "alt-version-series"; + context.BaseItems.Add(series); + + context.BaseItems.Add(CreateLeaf(primaryId)); + var alternate = CreateLeaf(alternateId); + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + context.SaveChanges(); + + // Only the primary is counted as a leaf, as ApplyAccessFiltering leaves it in production. + AddAncestor(context, primaryId, seriesId); + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = primaryId, + ChildId = alternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + // The file that was watched is the alternate, so the primary carries no played row. + context.UserData.Add(new UserData + { + ItemId = alternateId, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + // The per-item paths have to agree with the batch one, which the DTO uses interchangeably. + Assert.Equal(1, _service.GetPlayedCount(filter, seriesId)); + Assert.Equal((1, 1), _service.GetPlayedAndTotalCount(filter, seriesId)); + Assert.Equal((1, 1), _service.GetPlayedAndTotalCountBatch([seriesId], user)[seriesId]); + } + + [Fact] + public void GetCounts_MultiVersionMovie_CountPlaybackOfAnyVersion() + { + // Two movies held as two files each: the primary the collection links, and an alternate version + // linked to it. One movie was watched on its alternate, which is where playback of a second cut + // lands; the other was not watched at all. + var user = new User("alt-version-test", "provider", "reset"); + var boxSetId = Guid.NewGuid(); + var libraryId = Guid.NewGuid(); + var watchedPrimaryId = Guid.NewGuid(); + var watchedAlternateId = Guid.NewGuid(); + var unwatchedPrimaryId = Guid.NewGuid(); + var unwatchedAlternateId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + var boxSet = CreateItem(boxSetId); + boxSet.PresentationUniqueKey = "alt-version-box-set"; + context.BaseItems.Add(boxSet); + + var library = CreateItem(libraryId); + library.PresentationUniqueKey = "alt-version-library"; + context.BaseItems.Add(library); + + foreach (var (primaryId, alternateId) in + new[] { (watchedPrimaryId, watchedAlternateId), (unwatchedPrimaryId, unwatchedAlternateId) }) + { + context.BaseItems.Add(CreateLeaf(primaryId)); + + var alternate = CreateLeaf(alternateId); + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + } + + context.SaveChanges(); + + context.LinkedChildren.AddRange( + new LinkedChildEntity + { + ParentId = boxSetId, + ChildId = watchedPrimaryId, + ChildType = LinkedChildType.Manual, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = boxSetId, + ChildId = unwatchedPrimaryId, + ChildType = LinkedChildType.Manual, + SortOrder = 1 + }, + new LinkedChildEntity + { + ParentId = watchedPrimaryId, + ChildId = watchedAlternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }, + new LinkedChildEntity + { + ParentId = unwatchedPrimaryId, + ChildId = unwatchedAlternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + AddAncestor(context, watchedPrimaryId, libraryId); + AddAncestor(context, unwatchedPrimaryId, libraryId); + + context.UserData.Add(new UserData + { + ItemId = watchedAlternateId, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + // A version group is one item to count, and the alternate's playback makes that item played - + // as it already does for the played flag the primary itself reports. + Assert.Equal((1, 2), _service.GetPlayedAndTotalCountFromLinkedChildren(filter, boxSetId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCountBatch([boxSetId], user)[boxSetId]); + + // The ancestor-based paths answer the same for the library the primaries sit in. + Assert.Equal(1, _service.GetPlayedCount(filter, libraryId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, libraryId)); + } + + [Fact] + public void GetChildCountBatch_NoUser_StillCollapsesAlternateVersions() + { + // Both files of a merged movie sit in the folder. With a user it is access filtering that + // drops the alternate; with no user nothing else would, and the folder would report two + // children for the one title a viewer sees. + var folderId = Guid.NewGuid(); + var primaryId = Guid.NewGuid(); + var alternateId = Guid.NewGuid(); + var extraId = Guid.NewGuid(); + var ownedId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.Add(CreateItem(folderId)); + + var primary = CreateLeaf(primaryId); + primary.ParentId = folderId; + context.BaseItems.Add(primary); + + var alternate = CreateLeaf(alternateId); + alternate.ParentId = folderId; + alternate.PrimaryVersionId = primaryId; + context.BaseItems.Add(alternate); + + // An extra carries an owner and an extra type, and stays a child of its own. + var extra = CreateLeaf(extraId); + extra.ParentId = folderId; + extra.OwnerId = primaryId; + extra.ExtraType = BaseItemExtraType.Trailer; + context.BaseItems.Add(extra); + + // An owned item that is not an extra belongs to its owner, not to the folder. + var owned = CreateLeaf(ownedId); + owned.ParentId = folderId; + owned.OwnerId = primaryId; + context.BaseItems.Add(owned); + + context.SaveChanges(); + } + + Assert.Equal(2, _service.GetChildCountBatch([folderId], null)[folderId]); + } + + [Fact] + public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys() + { + var seriesA = Guid.NewGuid(); + var seriesB = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + foreach (var id in new[] { seriesA, seriesB }) + { + var series = CreateItem(id); + series.PresentationUniqueKey = "merged-series"; + context.BaseItems.Add(series); + } + + // Each folder-item holds a "Season 1"; those two share a key and are one season to the user. + var sharedSeasonA = CreateItem(Guid.NewGuid(), seriesA); + sharedSeasonA.PresentationUniqueKey = "merged-series-001"; + var sharedSeasonB = CreateItem(Guid.NewGuid(), seriesB); + sharedSeasonB.PresentationUniqueKey = "merged-series-001"; + var ownSeason = CreateItem(Guid.NewGuid(), seriesB); + ownSeason.PresentationUniqueKey = "merged-series-002"; + + context.BaseItems.AddRange(sharedSeasonA, sharedSeasonB, ownSeason); + context.SaveChanges(); + } + + var result = _service.GetChildCountBatch([seriesA, seriesB], null); + + Assert.Equal(2, result[seriesA]); + Assert.Equal(2, result[seriesB]); + } + + [Fact] + public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason() + { + var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false); + + var result = _service.GetChildCountBatch([seriesId, seasonId], null); + + Assert.Equal(2, result[seasonId]); + + // The series holds the season, not the episodes: counting those here would double them up. + Assert.Equal(1, result[seriesId]); + } + + [Fact] + public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce() + { + var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false); + + var result = _service.GetChildCountBatch([seriesId, seasonId], null); + + Assert.Equal(2, result[seasonId]); + Assert.Equal(1, result[seriesId]); + } + + [Fact] + public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem() + { + var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true); + var user = new User("count-test", "provider", "reset"); + + user.DisplayMissingEpisodes = true; + Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]); + + // Nothing this user can open, so nothing to report. + user.DisplayMissingEpisodes = false; + Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]); + } + + [Fact] + public void GetChildCountBatch_NoUser_CountsMissingEpisodes() + { + var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true); + + Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]); + } + + private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes) + { + var seriesId = Guid.NewGuid(); + var seasonId = Guid.NewGuid(); + + using var context = CreateDbContext(); + context.BaseItems.Add(CreateItem(seriesId)); + context.BaseItems.Add(CreateItem(seasonId, seriesId)); + + // Flat: the episodes sit in the series folder, so ParentId points at the series and only + // SeasonId ties them to the season they belong to. + for (var i = 0; i < 2; i++) + { + var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId); + episode.Type = "MediaBrowser.Controller.Entities.TV.Episode"; + episode.IsFolder = false; + episode.IsVirtualItem = virtualEpisodes; + episode.SeasonId = seasonId; + context.BaseItems.Add(episode); + } + + context.SaveChanges(); + + return (seriesId, seasonId); + } + + private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId) + { + var user = new User("count-test", "provider", "reset"); + var seriesA = Guid.NewGuid(); + var seriesB = Guid.NewGuid(); + var leafA = Guid.NewGuid(); + var leafB = Guid.NewGuid(); + playedLeafId = leafA; + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + foreach (var id in new[] { seriesA, seriesB }) + { + var series = CreateItem(id); + series.PresentationUniqueKey = "merged-series"; + context.BaseItems.Add(series); + } + + context.BaseItems.AddRange(CreateLeaf(leafA), CreateLeaf(leafB)); + context.SaveChanges(); + + AddAncestor(context, leafA, seriesA); + AddAncestor(context, leafB, seriesB); + + context.UserData.Add(new UserData + { + ItemId = leafA, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + return (user, seriesA, seriesB); + } + + private static void AddAncestor(JellyfinDbContext context, Guid itemId, Guid parentItemId) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = parentItemId, + Item = null!, + ParentItem = null! + }); + } + + private static BaseItemEntity CreateLeaf(Guid id) + { + return new BaseItemEntity + { + Id = id, + Type = "Episode", + IsFolder = false, + IsVirtualItem = false, + PresentationUniqueKey = id.ToString("N") + }; + } + + [Fact] + public void GetItemCountsForNameItems_MatchesCountingEachNameItemOnItsOwn() + { + // Three genres tagging a different number of movies each, plus one tagging nothing. + var genres = SeedGenres(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie, BaseItemKind.Series]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + // Every requested id is answered, so a caller can index the result without checking. + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + + // And the counts are the seeded ones rather than all zero, which would match trivially. + Assert.Equal([3, 2, 1, 0], genres.Select(g => batch[g].MovieCount).ToArray()); + } + + [Fact] + public void GetItemCountsForNameItems_UnknownId_CountsZero() + { + var unknown = Guid.NewGuid(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + [unknown], + [BaseItemKind.Movie], + new InternalItemsQuery()); + + Assert.Equal(0, batch[unknown].ItemCount); + } + + [Fact] + public void GetItemCountsForNameItems_ArtistTaggedTwiceOnOneAlbum_CountsTheAlbumOnce() + { + // An album whose artist is also its album artist maps to the same artist twice. + var artistId = SeedArtistWithAlbum(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.MusicAlbum]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.MusicArtist, [artistId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.MusicArtist, artistId, related, filter); + + Assert.Equal(1, batch[artistId].AlbumCount); + Assert.Equal(single.AlbumCount, batch[artistId].AlbumCount); + Assert.Equal(single.ItemCount, batch[artistId].ItemCount); + } + + /// <summary> + /// Seeds one artist and a single album tagged with it as both artist and album artist. + /// </summary> + /// <returns>The id of the seeded artist.</returns> + private Guid SeedArtistWithAlbum() + { + const string Name = "artist-0"; + var artistId = Guid.NewGuid(); + var albumId = Guid.NewGuid(); + + using var context = CreateDbContext(); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = Name; + artist.CleanName = Name; + context.BaseItems.Add(artist); + + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + foreach (var type in new[] { ItemValueType.Artist, ItemValueType.AlbumArtist }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = type, + Value = Name, + CleanValue = Name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = albumId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + + return artistId; + } + + [Fact] + public void GetItemCountsForNameItems_LargeIdSet_DoesNotExceedSqliteVariableLimit() + { + // Seeded rather than random, so the clean names of every one of them reach the second + // query's IN list and the join behind it, instead of stopping at the empty-name return. + var seeded = SeedArtists(50, out var taggedArtistId); + + var ids = seeded.Concat(Enumerable.Range(0, 40_000).Select(_ => Guid.NewGuid())).ToList(); + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, + ids, + [BaseItemKind.MusicAlbum], + new InternalItemsQuery()); + + Assert.Equal(ids.Count, batch.Count); + + // And the grouped query really ran, rather than every id coming back zeroed. + Assert.Equal(1, batch[taggedArtistId].AlbumCount); + } + + [Fact] + public void GetItemCountsForNameItems_QueryShape_DoesNotVaryWithBatchSize() + { + // Every id list has to be bound as one parameter rather than one placeholder each: that is + // what keeps the statement off the SQLite variable ceiling and out of a per-size entry in + // EF's compiled query cache. Identical SQL for two batch sizes is exactly that property. + var seeded = SeedArtists(6, out _); + + var small = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded.Take(2).ToList(), [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + var large = CaptureSql(() => _service.GetItemCountsForNameItems( + BaseItemKind.MusicArtist, seeded, [BaseItemKind.MusicAlbum], new InternalItemsQuery())); + + Assert.NotEmpty(small); + Assert.Equal(small, large); + } + + private List<string> CaptureSql(Action action) + { + _capturedSql = []; + try + { + action(); + return _capturedSql; + } + finally + { + _capturedSql = null; + } + } + + /// <summary> + /// Seeds the requested number of artists, each with a clean name of its own, one of which is + /// credited on a single album. + /// </summary> + /// <param name="count">The number of artists to seed.</param> + /// <param name="taggedArtistId">The id of the artist credited on an album.</param> + /// <returns>The ids of the seeded artists.</returns> + private List<Guid> SeedArtists(int count, out Guid taggedArtistId) + { + var ids = new List<Guid>(count); + using var context = CreateDbContext(); + + ItemValue? taggedValue = null; + taggedArtistId = Guid.Empty; + + for (var i = 0; i < count; i++) + { + var name = "bulk-artist-" + i.ToString(CultureInfo.InvariantCulture); + var artistId = Guid.NewGuid(); + ids.Add(artistId); + + var artist = CreateItem(artistId); + artist.Type = "MusicArtist"; + artist.Name = name; + artist.CleanName = name; + context.BaseItems.Add(artist); + + if (i == 0) + { + taggedArtistId = artistId; + taggedValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Artist, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(taggedValue); + } + } + + context.SaveChanges(); + + var albumId = Guid.NewGuid(); + var album = CreateItem(albumId); + album.Type = "MusicAlbum"; + context.BaseItems.Add(album); + context.SaveChanges(); + + Tag(context, albumId, taggedValue!.ItemValueId); + context.SaveChanges(); + + return ids; + } + + [Fact] + public void GetItemCountsForNameItems_KindWithoutItemValues_FallsBackToTheSingleItemPath() + { + // Year is keyed by ProductionYear rather than a cleaned item value, so it cannot be grouped. + var yearId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + var year = CreateItem(yearId); + year.Type = "Year"; + year.Name = "2001"; + year.CleanName = "2001"; + context.BaseItems.Add(year); + + for (var i = 0; i < 2; i++) + { + var movie = CreateItem(Guid.NewGuid()); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = 2001; + context.BaseItems.Add(movie); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Year, [yearId], related, filter); + var single = _service.GetItemCountsForNameItem(BaseItemKind.Year, yearId, related, filter); + + Assert.Equal(2, batch[yearId].MovieCount); + Assert.Equal(single.MovieCount, batch[yearId].MovieCount); + } + + [Fact] + public void GetItemCountsForNameItems_PeopleAndYears_AreBatchedToo() + { + var (personIds, yearIds) = SeedPeopleAndYears(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + foreach (var (kind, ids) in new[] { (BaseItemKind.Person, personIds), (BaseItemKind.Year, yearIds) }) + { + var contextsBefore = _contextsCreated; + var batch = _service.GetItemCountsForNameItems(kind, ids, related, filter); + + // These two used to be answered one query per id; only the value keyed kinds batched. + Assert.Equal(1, _contextsCreated - contextsBefore); + + Assert.Equal(ids.Count, batch.Count); + Assert.Equal(2, batch[ids[0]].MovieCount); + Assert.Equal(1, batch[ids[1]].MovieCount); + + foreach (var id in ids) + { + var single = _service.GetItemCountsForNameItem(kind, id, related, filter); + Assert.Equal(single.MovieCount, batch[id].MovieCount); + Assert.Equal(single.ItemCount, batch[id].ItemCount); + } + } + } + + /// <summary> + /// Seeds two people and two years, the first of each on two movies and the second on one. + /// </summary> + /// <returns>The ids of the seeded people and years.</returns> + private (List<Guid> PersonIds, List<Guid> YearIds) SeedPeopleAndYears() + { + var personIds = new List<Guid>(); + var yearIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 2; i++) + { + var personName = "person-" + i.ToString(CultureInfo.InvariantCulture); + var personId = Guid.NewGuid(); + personIds.Add(personId); + + var person = CreateItem(personId); + person.Type = "Person"; + person.Name = personName; + person.CleanName = personName; + context.BaseItems.Add(person); + + var people = new People { Id = Guid.NewGuid(), Name = personName }; + context.Peoples.Add(people); + + var year = 2000 + i; + var yearId = Guid.NewGuid(); + yearIds.Add(yearId); + + var yearItem = CreateItem(yearId); + yearItem.Type = "Year"; + yearItem.Name = year.ToString(CultureInfo.InvariantCulture); + yearItem.CleanName = yearItem.Name; + context.BaseItems.Add(yearItem); + context.SaveChanges(); + + // Two movies for the first of each, one for the second. + for (var m = 0; m < 2 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + movie.ProductionYear = year; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.PeopleBaseItemMap.Add(new PeopleBaseItemMap + { + ItemId = movieId, + PeopleId = people.Id, + Item = null!, + People = null!, + Role = "Actor", + ListOrder = m, + SortOrder = m + }); + } + + context.SaveChanges(); + } + + return (personIds, yearIds); + } + + [Theory] + // The set the by-name listing actually asks for: it rolls the episodes of a tagged series up + // into the genre, which is the case the batch has to reproduce query for query. + [InlineData(BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie)] + // And the same seeded data without the roll-up, which takes the plain grouped path. + [InlineData(BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicAlbum)] + public void GetItemCountsForNameItems_TaggedSeriesAndEpisodes_MatchesCountingEachNameItemOnItsOwn( + BaseItemKind first, + BaseItemKind second, + BaseItemKind third) + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [first, second, third]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter); + + Assert.Equal(genres.Count, batch.Count); + + foreach (var genreId in genres) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TaggedSeries_RollsEpisodesUpIntoTheGenre() + { + var genres = SeedGenresTaggingSeriesAndEpisodes(); + + var contextsBefore = _contextsCreated; + + var batch = _service.GetItemCountsForNameItems( + BaseItemKind.Genre, + genres, + [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie], + new InternalItemsQuery()); + + // The whole point of the batch: one context for every genre on the page, not one each. + // The roll-up used to force this shape back onto the single item path. + Assert.Equal(1, _contextsCreated - contextsBefore); + + // "rolled": one tagged series of two episodes, one of which carries the genre itself, plus + // a loose tagged episode of an untagged series. The tagged episode of the tagged series + // must not be counted twice. + Assert.Equal(3, batch[genres[0]].EpisodeCount); + Assert.Equal(1, batch[genres[0]].SeriesCount); + + // "loose": a tagged episode whose series carries no genre at all. + Assert.Equal(1, batch[genres[1]].EpisodeCount); + Assert.Equal(0, batch[genres[1]].SeriesCount); + + // "empty": tags nothing. + Assert.Equal(0, batch[genres[2]].EpisodeCount); + } + + [Fact] + public void GetItemCountsForNameItems_EpisodeAndItsSeriesTaggedDifferently_KeepsTheGenresApart() + { + var seriesId = Guid.NewGuid(); + var episodeId = Guid.NewGuid(); + var genreIds = new List<Guid>(); + + using (var context = CreateDbContext()) + { + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "on-series", "on-episode" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + var series = CreateItem(seriesId); + series.Type = "Series"; + context.BaseItems.Add(series); + context.BaseItems.Add(CreateEpisode(episodeId, seriesId)); + context.SaveChanges(); + + Tag(context, seriesId, values["on-series"]); + Tag(context, episodeId, values["on-episode"]); + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genreIds, related, filter); + + // The episode rolls up into the genre on its series. + Assert.Equal(1, batch[genreIds[0]].EpisodeCount); + + // Its own genre is carried by no series, so the episode stays a direct count there. Keyed + // on the series id alone the episode would be subtracted here and this would read 0. + Assert.Equal(1, batch[genreIds[1]].EpisodeCount); + Assert.Equal(0, batch[genreIds[1]].SeriesCount); + + foreach (var genreId in genreIds) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount); + Assert.Equal(single.ItemCount, batch[genreId].ItemCount); + } + } + + [Fact] + public void GetItemCountsForNameItems_TwoNameItemsSharingACleanName_BothGetTheCounts() + { + // Distinct rows cleaning down to one name are what the batch keys on; the unique index + // permits them, so two genre items can legitimately share a clean name. + var firstId = Guid.NewGuid(); + var secondId = Guid.NewGuid(); + var movieId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + foreach (var (id, name) in new[] { (firstId, "Sci-Fi"), (secondId, "SCI-FI") }) + { + var genre = CreateItem(id); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = "sci-fi"; + context.BaseItems.Add(genre); + } + + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + foreach (var name in new[] { "Sci-Fi", "SCI-FI" }) + { + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = "sci-fi" + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + Tag(context, movieId, itemValue.ItemValueId); + } + + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(); + BaseItemKind[] related = [BaseItemKind.Movie]; + + var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, [firstId, secondId], related, filter); + + // One movie, reached through two value rows: counted once for each genre item, not twice. + Assert.Equal(1, batch[firstId].MovieCount); + Assert.Equal(1, batch[secondId].MovieCount); + + foreach (var genreId in new[] { firstId, secondId }) + { + var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter); + Assert.Equal(single.MovieCount, batch[genreId].MovieCount); + } + } + + /// <summary> + /// Seeds three genres: one tagging a series whose episodes roll up (one of them tagged too) + /// plus a loose episode, one tagging only an episode of an untagged series, and one tagging + /// nothing. + /// </summary> + /// <returns>The ids of the seeded genres, in that order.</returns> + private List<Guid> SeedGenresTaggingSeriesAndEpisodes() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + var values = new Dictionary<string, Guid>(StringComparer.Ordinal); + foreach (var name in new[] { "rolled", "loose", "empty" }) + { + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + values[name] = itemValue.ItemValueId; + } + + context.SaveChanges(); + + // A series tagged "rolled" holding two episodes; the second carries "rolled" itself, so the + // roll-up and the direct tag both see it. + var taggedSeriesId = Guid.NewGuid(); + var taggedSeries = CreateItem(taggedSeriesId); + taggedSeries.Type = "Series"; + context.BaseItems.Add(taggedSeries); + + var episodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + var taggedEpisodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId); + context.BaseItems.AddRange(episodeOfTaggedSeries, taggedEpisodeOfTaggedSeries); + + // An untagged series whose episode carries a genre on its own. + var untaggedSeriesId = Guid.NewGuid(); + var untaggedSeries = CreateItem(untaggedSeriesId); + untaggedSeries.Type = "Series"; + context.BaseItems.Add(untaggedSeries); + + var looseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + var rolledLooseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId); + context.BaseItems.AddRange(looseEpisode, rolledLooseEpisode); + + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + + context.SaveChanges(); + + Tag(context, taggedSeriesId, values["rolled"]); + Tag(context, taggedEpisodeOfTaggedSeries.Id, values["rolled"]); + Tag(context, rolledLooseEpisode.Id, values["rolled"]); + Tag(context, looseEpisode.Id, values["loose"]); + Tag(context, movieId, values["rolled"]); + + context.SaveChanges(); + + return genreIds; + } + + private static void Tag(JellyfinDbContext context, Guid itemId, Guid itemValueId) + { + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = itemId, + ItemValueId = itemValueId, + Item = null!, + ItemValue = null! + }); + } + + private static BaseItemEntity CreateEpisode(Guid id, Guid seriesId) + { + return new BaseItemEntity + { + Id = id, + Type = "Episode", + IsFolder = false, + IsVirtualItem = false, + ParentId = seriesId, + SeriesId = seriesId + }; + } + + /// <summary> + /// Seeds four genres tagging three, two, one and no movies, in that order. + /// </summary> + /// <returns>The ids of the seeded genres.</returns> + private List<Guid> SeedGenres() + { + var genreIds = new List<Guid>(); + + using var context = CreateDbContext(); + + for (var i = 0; i < 4; i++) + { + var name = "genre-" + i.ToString(CultureInfo.InvariantCulture); + var genreId = Guid.NewGuid(); + genreIds.Add(genreId); + + var genre = CreateItem(genreId); + genre.Type = "Genre"; + genre.Name = name; + genre.CleanName = name; + context.BaseItems.Add(genre); + + var itemValue = new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = name, + CleanValue = name + }; + context.ItemValues.Add(itemValue); + context.SaveChanges(); + + // 3 movies for the first genre, 2 for the second, 1 for the third, none for the last. + for (var m = 0; m < 3 - i; m++) + { + var movieId = Guid.NewGuid(); + var movie = CreateItem(movieId); + movie.Type = "Movie"; + movie.IsFolder = false; + context.BaseItems.Add(movie); + context.SaveChanges(); + + context.ItemValuesMap.Add(new ItemValueMap + { + ItemId = movieId, + ItemValueId = itemValue.ItemValueId, + Item = null!, + ItemValue = null! + }); + } + + context.SaveChanges(); + } + + return genreIds; + } + + private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null) + { + return new BaseItemEntity + { + Id = id, + Type = "Folder", + ParentId = parentId, + IsFolder = true + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider( + _applicationPaths, + NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs new file mode 100644 index 0000000000..c15ea09965 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers the invariant that a video linked as an alternate version also carries the +/// PrimaryVersionId the item queries hide it by, including when it was already a library +/// item in its own right before it became a version. +/// </summary> +public sealed class ItemPersistenceAlternateVersionTests : SqliteDbTestFixture +{ + private const string PrimaryPath = "/movies/Movie/Movie - 4K.mkv"; + private const string VersionPath = "/movies/Movie/Movie - 1080p.mkv"; + + private readonly ItemPersistenceService _service; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IServerConfigurationManager? _previousConfigurationManager; + private readonly IRecordingsManager? _previousRecordingsManager; + + public ItemPersistenceAlternateVersionTests() + { + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousConfigurationManager = BaseItem.ConfigurationManager; + _previousRecordingsManager = Video.RecordingsManager; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>())) + .Returns([]); + BaseItem.LibraryManager = libraryManager.Object; + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + BaseItem.ConfigurationManager = configurationManager.Object; + + // Video.SourceType asks this whether the file is an in-progress recording. + Video.RecordingsManager = new Mock<IRecordingsManager>().Object; + + // Paths round-trip through the host's virtual path mapping on the way in and out. + var appHost = new Mock<IServerApplicationHost>(); + appHost.Setup(h => h.ReverseVirtualPath(It.IsAny<string>())).Returns((string p) => p); + appHost.Setup(h => h.ExpandVirtualPath(It.IsAny<string>())).Returns((string p) => p); + + _service = new ItemPersistenceService( + CreateDbContextFactory(), + appHost.Object, + NullLogger<ItemPersistenceService>.Instance); + } + + protected override void Dispose(bool disposing) + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.ConfigurationManager = _previousConfigurationManager!; + Video.RecordingsManager = _previousRecordingsManager!; + base.Dispose(disposing); + } + + [Fact] + public void SaveItems_LocalAlternateVersionAlreadyAnItem_SetsPrimaryVersionId() + { + var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + // The version was scanned as a standalone movie before it became a version, so it has a + // presentation key of its own and no PrimaryVersionId. + var version = CreateMovie(versionId, VersionPath); + version.PresentationUniqueKey = "standalone"; + _service.SaveItems([version], CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId); + } + + // Now the scan folds it into a primary, which is the item that gets saved. + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LocalAlternateVersions = [VersionPath]; + _service.SaveItems([primary], CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId))); + Assert.Equal(DbLinkedChildType.LocalAlternateVersion, link.ChildType); + Assert.Equal(versionId, link.ChildId); + + var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId)); + Assert.Equal(primaryId, stored.PrimaryVersionId); + + // Presentation-key grouping has to collapse it onto the primary as well. + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey); + } + } + + [Fact] + public void SaveItems_LinkedAlternateVersionAlreadyAnItem_SetsPrimaryVersionId() + { + var primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + _service.SaveItems([CreateMovie(versionId, VersionPath)], CancellationToken.None); + + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LinkedAlternateVersions = + [ + new LinkedChild { ItemId = versionId, Type = LinkedChildType.LinkedAlternateVersion } + ]; + _service.SaveItems([primary], CancellationToken.None); + + using var ctx = CreateDbContext(); + var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId))); + Assert.Equal(DbLinkedChildType.LinkedAlternateVersion, link.ChildType); + Assert.Equal(primaryId, ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId); + } + + [Fact] + public void SaveItems_VersionAlreadyPointingAtPrimary_LeavesItAlone() + { + var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + var version = CreateMovie(versionId, VersionPath); + version.SetPrimaryVersionId(primaryId); + _service.SaveItems([version], CancellationToken.None); + + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LocalAlternateVersions = [VersionPath]; + _service.SaveItems([primary], CancellationToken.None); + + using var ctx = CreateDbContext(); + var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId)); + Assert.Equal(primaryId, stored.PrimaryVersionId); + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey); + } + + [Fact] + public void SaveItems_VideoListedAmongItsOwnVersions_KeepsItsOwnPrimaryVersionId() + { + var primaryId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + var primary = CreateMovie(primaryId, PrimaryPath); + primary.LocalAlternateVersions = [PrimaryPath]; + _service.SaveItems([primary], CancellationToken.None); + + using var ctx = CreateDbContext(); + Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(primaryId)).PrimaryVersionId); + } + + [Fact] + public void SaveItems_PromotedVersionStillPointingAtOldPrimary_DoesNotCreateACycle() + { + var promotedId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var oldPrimaryId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + _service.SaveItems([CreateMovie(oldPrimaryId, VersionPath)], CancellationToken.None); + + // The rescan resolves this one as the primary of the group, but it still carries the pointer + // to the version it was promoted over. + var promoted = CreateMovie(promotedId, PrimaryPath); + promoted.SetPrimaryVersionId(oldPrimaryId); + promoted.LocalAlternateVersions = [VersionPath]; + _service.SaveItems([promoted], CancellationToken.None); + + using var ctx = CreateDbContext(); + + // Pointing the old primary back would hide both, and with them the whole group. + Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(oldPrimaryId)).PrimaryVersionId); + Assert.Equal(oldPrimaryId, ctx.BaseItems.First(e => e.Id.Equals(promotedId)).PrimaryVersionId); + } + + private static Movie CreateMovie(Guid id, string path) => new() + { + Id = id, + Name = "Movie", + Path = path + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs new file mode 100644 index 0000000000..e2bdd9e0b2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// DeleteItem has to hand SQLite one statement that already contains everything the foreign keys +/// on BaseItems require, because FK_BaseItems_BaseItems_OwnerId is NO ACTION: anything left behind +/// pointing at a deleted row fails the whole delete with SQLite error 19. +/// </summary> +public sealed class ItemPersistenceDeleteItemTests : SqliteDbTestFixture +{ + private static readonly Guid _owner = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + private static readonly Guid _extra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001"); + private static readonly Guid _extraOfExtra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000002"); + private static readonly Guid _child = Guid.Parse("cccccccc-0000-0000-0000-000000000001"); + private static readonly Guid _extraOfChild = Guid.Parse("eeeeeeee-0000-0000-0000-000000000003"); + + private readonly ItemPersistenceService _service; + + public ItemPersistenceDeleteItemTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + new Mock<IServerApplicationHost>().Object, + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public void DeleteItem_OwnerIdChain_DeletesWholeChain() + { + // An extra that owns an extra of its own. Real libraries carry these in bulk, and a single + // expansion pass over OwnerId leaves the second level behind. + Seed( + (_owner, null, null), + (_extra, _owner, null), + (_extraOfExtra, _extra, null)); + + _service.DeleteItem([_owner]); + + using var context = CreateDbContext(); + Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra) || e.Id.Equals(_extraOfExtra))); + } + + [Fact] + public void DeleteItem_ExtraOwnedByCascadedChild_DeletesExtraToo() + { + // The child goes away through FK_BaseItems_BaseItems_ParentId's ON DELETE CASCADE whether or + // not it is listed, so an extra owned by that child has to be listed with it. + Seed( + (_owner, null, null), + (_child, null, _owner), + (_extraOfChild, _child, null)); + + _service.DeleteItem([_owner]); + + using var context = CreateDbContext(); + Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_child) || e.Id.Equals(_extraOfChild))); + } + + [Fact] + public void DeleteItem_OwnershipCycle_Terminates() + { + // A malformed pair that owns each other must not spin the closure loop forever. + Seed((_owner, null, null), (_extra, _owner, null)); + + using (var context = CreateDbContext()) + { + context.BaseItems.Single(e => e.Id.Equals(_owner)).OwnerId = _extra; + context.SaveChanges(); + } + + _service.DeleteItem([_owner]); + + using var assertContext = CreateDbContext(); + Assert.Empty(assertContext.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra))); + } + + private void Seed(params (Guid Id, Guid? OwnerId, Guid? ParentId)[] items) + { + using var context = CreateDbContext(); + + // Owners before the rows referencing them: the seed itself is foreign key checked. + foreach (var (id, ownerId, parentId) in items) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = "MediaBrowser.Controller.Entities.Video", + OwnerId = ownerId, + ParentId = parentId + }); + + context.SaveChanges(); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs new file mode 100644 index 0000000000..82614c3156 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture +{ + private readonly ItemPersistenceService _service; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IServerConfigurationManager? _previousConfigurationManager; + + public ItemPersistenceOwnedRowTests() + { + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousConfigurationManager = BaseItem.ConfigurationManager; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>())) + .Returns([]); + BaseItem.LibraryManager = libraryManager.Object; + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + BaseItem.ConfigurationManager = configurationManager.Object; + + _service = new ItemPersistenceService( + CreateDbContextFactory(), + new Mock<IServerApplicationHost>().Object, + NullLogger<ItemPersistenceService>.Instance); + } + + protected override void Dispose(bool disposing) + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.ConfigurationManager = _previousConfigurationManager!; + base.Dispose(disposing); + } + + [Fact] + public void SaveItems_UpdateExistingItem_ReplacesOwnedRows() + { + var id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + _service.SaveItems( + [CreateBook(id, new() { ["Imdb"] = "tt0001", ["Tmdb"] = "555" }, [MetadataField.Name])], + CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + Assert.Equal(2, ctx.BaseItemProviders.Count(e => e.ItemId.Equals(id))); + Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id))); + Assert.Equal(1, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id))); + } + + // Re-save with different owned rows: the update path rewrites all three tables wholesale. + _service.SaveItems( + [CreateBook(id, new() { ["Imdb"] = "tt9999" }, [MetadataField.Name, MetadataField.Genres])], + CancellationToken.None); + + using (var ctx = CreateDbContext()) + { + var providers = ctx.BaseItemProviders.Where(e => e.ItemId.Equals(id)).ToList(); + Assert.Equal("tt9999", Assert.Single(providers).ProviderValue); + + Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id))); + Assert.Equal(2, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id))); + } + } + + [Fact] + public void SaveItems_MixedNewAndExistingBatch_ReplacesOnlyExistingOwnedRows() + { + var existing = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + var fresh = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + + _service.SaveItems([CreateBook(existing, new() { ["Imdb"] = "tt0001" }, [])], CancellationToken.None); + + // One already-persisted item and one brand new item in the same batch. + _service.SaveItems( + [ + CreateBook(existing, new() { ["Imdb"] = "tt0002" }, []), + CreateBook(fresh, new() { ["Tmdb"] = "777" }, []) + ], + CancellationToken.None); + + using var ctx = CreateDbContext(); + Assert.Equal("tt0002", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(existing))).ProviderValue); + Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue); + } + + private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields) + { + var book = new Book + { + Id = id, + Name = "Book", + ProviderIds = providerIds, + LockedFields = lockedFields + }; + + book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0); + return book; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs new file mode 100644 index 0000000000..fc28025573 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class ItemPersistencePeopleCleanupTests : SqliteDbTestFixture +{ + private readonly ItemPersistenceService _service; + + public ItemPersistencePeopleCleanupTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + Mock.Of<IServerApplicationHost>(), + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public void DeleteItem_RemovesUnusedPeopleForItemsDescendantsAndExtras() + { + var parent = CreateItem(isFolder: true); + var child = CreateItem(); + child.ParentId = parent.Id; + var extra = CreateItem(); + extra.OwnerId = child.Id; + var survivor = CreateItem(); + var shared = CreatePerson("Shared person"); + var unrelatedOrphan = CreatePerson("Unrelated orphan"); + using (var context = CreateDbContext()) + { + context.PeopleBaseItemMap.AddRange( + Map(parent, CreatePerson("Parent credit")), + Map(child, CreatePerson("Child credit")), + Map(extra, CreatePerson("Extra credit")), + Map(child, shared), + Map(survivor, shared)); + context.Peoples.Add(unrelatedOrphan); + context.AncestorIds.Add(new AncestorId + { + ItemId = child.Id, + Item = child, + ParentItemId = parent.Id, + ParentItem = parent + }); + context.SaveChanges(); + } + + _service.DeleteItem([parent.Id]); + + using var after = CreateDbContext(); + Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id); + Assert.Equal(survivor.Id, Assert.Single(after.PeopleBaseItemMap).ItemId); + Assert.Equal(new[] { shared.Id, unrelatedOrphan.Id }.Order(), after.Peoples.Select(e => e.Id).Order()); + } + + private static BaseItemEntity CreateItem(bool isFolder = false) => new() + { + Id = Guid.NewGuid(), + Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!, + IsFolder = isFolder + }; + + private static People CreatePerson(string name) => new() + { + Id = Guid.NewGuid(), + Name = name, + PersonType = "Actor" + }; + + private static PeopleBaseItemMap Map(BaseItemEntity item, People person) => new() + { + ItemId = item.Id, + Item = item, + PeopleId = person.Id, + People = person, + Role = string.Empty + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs new file mode 100644 index 0000000000..7997c6d771 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture +{ + private readonly ItemPersistenceService _service; + + public ItemPersistenceServiceSaveImagesTests() + { + _service = new ItemPersistenceService( + CreateDbContextFactory(), + Mock.Of<IServerApplicationHost>(), + NullLogger<ItemPersistenceService>.Instance); + } + + [Fact] + public async Task SaveImagesAsync_ReplacesThePreviousImages() + { + var itemId = Guid.NewGuid(); + Seed(itemId); + + await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken); + await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken); + + using var context = CreateDbContext(); + var paths = context.BaseItemImageInfos + .Where(e => e.ItemId.Equals(itemId)) + .Select(e => e.Path) + .ToList(); + + Assert.Equal(["/second.jpg"], paths); + } + + [Fact] + public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp() + { + // A scan can delete the item between the refresh reading it and the images being written. That + // must not fail the whole refresh, and must not leave the images of an item that is gone. + var itemId = Guid.NewGuid(); + + await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken); + + using var context = CreateDbContext(); + Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId))); + } + + private static BaseItem CreateItem(Guid itemId, string imagePath) + => new Folder + { + Id = itemId, + ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }] + }; + + private void Seed(Guid itemId) + { + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = itemId, + Type = "Folder", + IsFolder = true + }); + context.SaveChanges(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs new file mode 100644 index 0000000000..8ed3c61a59 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one +/// whose alternate carries the played row, so an episode already seen must not be offered again. +/// </summary> +public sealed class NextUpServiceTests : SqliteDbTestFixture +{ + private const string SeriesKey = "next-up-series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private readonly NextUpService _service; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + private readonly Guid _playedViaAlternate = Guid.NewGuid(); + private readonly Guid _unplayed = Guid.NewGuid(); + + public NextUpServiceTests() + { + var itemTypeLookup = new ItemTypeLookup(); + + using (var context = CreateDbContext()) + { + Seed(context); + } + + _service = new NextUpService( + CreateDbContextFactory(), + itemTypeLookup, + CreateBaseItemRepository(itemTypeLookup)); + } + + [Fact] + public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt() + { + var batch = _service.GetNextUpEpisodesBatch( + new InternalItemsQuery(_user), + [SeriesKey], + includeSpecials: false, + includeWatchedForRewatching: false)[SeriesKey]; + + Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id); + Assert.Equal(_unplayed, batch.NextUp?.Id); + } + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + AddEpisode(context, _playedViaAlternate, 1); + AddEpisode(context, _unplayed, 2); + + // The second file of the first episode, and the only row the playback was recorded against. + // It presents under its primary's key, which is what keeps it out of the candidate list. + var alternateId = Guid.NewGuid(); + context.BaseItems.Add(new BaseItemEntity + { + Id = alternateId, + Type = EpisodeType, + Name = "Episode 1 4K", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = 1, + PresentationUniqueKey = _playedViaAlternate.ToString("N"), + PrimaryVersionId = _playedViaAlternate + }); + + context.SaveChanges(); + + // The link the scanner writes alongside PrimaryVersionId, and the hop the played state + // reaches the alternate through. + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _playedViaAlternate, + ChildId = alternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + context.UserData.Add(new UserData + { + ItemId = alternateId, + UserId = _user.Id, + CustomDataKey = alternateId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); + + context.SaveChanges(); + } + + private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = EpisodeType, + Name = $"Episode {indexNumber}", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = indexNumber, + PresentationUniqueKey = id.ToString("N") + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs new file mode 100644 index 0000000000..649458f733 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture +{ + private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + private readonly PeopleRepository _repository; + + public PeopleRepositoryUpdatePeopleTests() + { + var itemTypeLookup = new ItemTypeLookup(); + + using (var ctx = CreateDbContext()) + { + ctx.BaseItems.Add(new BaseItemEntity + { + Id = _itemId, + Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie], + Name = "Movie", + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }); + ctx.SaveChanges(); + } + + _repository = new PeopleRepository( + CreateDbContextFactory(), + itemTypeLookup, + new Mock<IItemQueryHelpers>().Object); + } + + [Fact] + public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel"), + CreatePerson("Person A", PersonKind.Writer, "Screenplay") + ]); + + using var ctx = CreateDbContext(); + Assert.Single(ctx.Peoples); + Assert.Equal( + ["Novel", "Screenplay"], + ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("person a", PersonKind.Actor, "hero") + ]); + + using var ctx = CreateDbContext(); + Assert.Single(ctx.Peoples); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Hero", map.Role); + } + + [Fact] + public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person A", PersonKind.Director, string.Empty) + ]); + + using var ctx = CreateDbContext(); + Assert.Equal(2, ctx.Peoples.Count()); + Assert.Equal(2, ctx.PeopleBaseItemMap.Count()); + } + + [Fact] + public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person B", PersonKind.Actor, "Sidekick") + ]); + + Guid[] peopleIdsBefore; + using (var ctx = CreateDbContext()) + { + peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray(); + } + + // Reversed order, so the list order of both mappings has to be rewritten. + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person B", PersonKind.Actor, "Sidekick"), + CreatePerson("Person A", PersonKind.Actor, "Hero") + ]); + + using var after = CreateDbContext(); + Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray()); + Assert.Equal( + ["Sidekick", "Hero"], + after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel"), + CreatePerson("Person A", PersonKind.Writer, "Screenplay") + ]); + + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Writer, "Novel") + ]); + + using var ctx = CreateDbContext(); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Novel", map.Role); + } + + [Fact] + public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]); + + using var ctx = CreateDbContext(); + var map = Assert.Single(ctx.PeopleBaseItemMap); + Assert.Equal("Hero", map.Role); + } + + [Fact] + public void UpdatePeople_CreditDroppedByTheProvider_LeavesNoCreditRowBehind() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person B", PersonKind.Actor, "Villain") + ]); + + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + using var ctx = CreateDbContext(); + Assert.Equal(["Person A"], ctx.Peoples.Select(e => e.Name).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditStillHeldByAnotherItem_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + _repository.UpdatePeople(AddMovie("Other Movie"), [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + _repository.UpdatePeople(_itemId, []); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + Assert.Single(after.PeopleBaseItemMap); + } + + [Fact] + public void DeleteOrphanedCredits_CreditNoItemMapsTo_IsDeleted() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + using (var ctx = CreateDbContext()) + { + // The state a credit was left in before UpdatePeople cleaned up after itself. + ctx.PeopleBaseItemMap.RemoveRange(ctx.PeopleBaseItemMap); + ctx.SaveChanges(); + } + + Assert.Equal(1, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Empty(after.Peoples); + } + + [Fact] + public void DeleteOrphanedCredits_CreditAnItemMapsTo_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + Assert.Equal(0, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + } + + private Guid AddMovie(string name) + { + var id = Guid.NewGuid(); + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie], + Name = name, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }); + ctx.SaveChanges(); + return id; + } + + private static PersonInfo CreatePerson(string name, PersonKind type, string role) + { + return new PersonInfo + { + Name = name, + Type = type, + Role = role + }; + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs new file mode 100644 index 0000000000..b925f98197 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public sealed class PeopleUpdateQueryTests : SqliteDbTestFixture +{ + private readonly CommandRecorder _recorder; + private readonly Guid _itemId = Guid.NewGuid(); + private readonly PeopleRepository _people; + + public PeopleUpdateQueryTests() + : this(new CommandRecorder()) + { + } + + private PeopleUpdateQueryTests(CommandRecorder recorder) + : base(recorder) + { + _recorder = recorder; + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = _itemId, + Name = "Movie", + Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie] + }); + context.SaveChanges(); + _people = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), Mock.Of<IItemQueryHelpers>()); + } + + [Theory] + [InlineData("Hero")] + [InlineData("HERO")] + public void UnchangedCredits_DoNotWriteOrLookUpAllPeople(string role) + { + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, Role = "Hero" }]); + _recorder.Commands.Clear(); + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "actor", Type = PersonKind.Actor, Role = role }]); + Assert.Single(_recorder.Commands); + Assert.StartsWith("SELECT", _recorder.Commands[0].Sql, StringComparison.Ordinal); + using var context = CreateDbContext(); + Assert.Equal("Hero", Assert.Single(context.PeopleBaseItemMap).Role); + } + + [Fact] + public void SortOrderChange_IsPersisted() + { + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 1 }]); + _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 2 }]); + using var context = CreateDbContext(); + Assert.Equal(2, Assert.Single(context.PeopleBaseItemMap).SortOrder); + } + + [Fact] + public void UpdatePeople_GeneratedSqlUsesPeopleNameIndex() + { + ApplyMigration(new Jellyfin.Server.Implementations.Migrations.AddPeopleNameLowerIndex()); + _recorder.Commands.Clear(); + _people.UpdatePeople(_itemId, [ + new PersonInfo { Name = "Actor A", Type = PersonKind.Actor }, + new PersonInfo { Name = "Actor B", Type = PersonKind.Actor } + ]); + var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("lower(\"p\".\"Name\")", StringComparison.Ordinal)); + Assert.Contains(Explain(query), line => line.Contains("SEARCH p USING INDEX IX_Peoples_NameLower", StringComparison.Ordinal)); + } + + private void ApplyMigration(Migration migration) + { + using var context = CreateDbContext(); + foreach (var operation in migration.UpOperations.Cast<SqlOperation>()) + { + context.Database.ExecuteSqlRaw(operation.Sql); + } + } + + private string[] Explain(RecordedCommand query) + { + using var context = CreateDbContext(); + using var command = context.Database.GetDbConnection().CreateCommand(); +#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters. + command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql; +#pragma warning restore CA2100 + foreach (var value in query.Parameters) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = value.Name; + parameter.Value = value.Value; + command.Parameters.Add(parameter); + } + + using var reader = command.ExecuteReader(); + var plan = new List<string>(); + while (reader.Read()) + { + plan.Add(reader.GetString(3)); + } + + return plan.ToArray(); + } + + private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters); + + private sealed class CommandRecorder : DbCommandInterceptor + { + public List<RecordedCommand> Commands { get; } = []; + + public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) + { + Record(command); + return result; + } + + public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result) + { + Record(command); + return result; + } + + private void Record(DbCommand command) => Commands.Add(new RecordedCommand( + command.CommandText, + command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray())); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs new file mode 100644 index 0000000000..6da176b4f1 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs @@ -0,0 +1,90 @@ +using System; +using System.Threading; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Base fixture for the item tests that run against the SQLite provider: one in-memory database per +/// test class, plus the wiring the repositories under test need. The connection owns the database, so +/// it stays open for the lifetime of the fixture. Derived classes seed in their own constructor. +/// </summary> +public abstract class SqliteDbTestFixture : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + + protected SqliteDbTestFixture(params IInterceptor[] interceptors) + { + ApplicationPaths = new Mock<IApplicationPaths>().Object; + + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .AddInterceptors(interceptors) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + protected IApplicationPaths ApplicationPaths { get; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(ApplicationPaths, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + + protected IDbContextFactory<JellyfinDbContext> CreateDbContextFactory() + { + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + return factory.Object; + } + + protected BaseItemRepository CreateBaseItemRepository(ItemTypeLookup itemTypeLookup) + { + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + return new BaseItemRepository( + CreateDbContextFactory(), + new Mock<IServerApplicationHost>().Object, + itemTypeLookup, + serverConfigurationManager.Object, + NullLogger<BaseItemRepository>.Instance); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _connection.Dispose(); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index 562711337f..a28c1d6dfb 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Naming.Common; @@ -17,6 +18,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using Moq; using Xunit; @@ -38,9 +40,15 @@ public class FindExtrasTests itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null); _fileSystemMock = fixture.Freeze<Mock<IFileSystem>>(); _fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path }); + + var strings = LoadCoreStrings(); + fixture.Freeze<Mock<ILocalizationManager>>() + .Setup(l => l.GetServerLocalizedString(It.IsAny<string>())) + .Returns<string>(key => strings.TryGetValue(key, out var value) ? value : key); + _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts( fixture.Create<IEnumerable<IResolverIgnoreRule>>(), - new List<IItemResolver> { new AudioResolver(fixture.Create<NamingOptions>()) }, + [new AudioResolver(fixture.Create<NamingOptions>())], fixture.Create<IEnumerable<IIntroProvider>>(), fixture.Create<IEnumerable<IBaseItemComparer>>(), fixture.Create<IEnumerable<ILibraryPostScanTask>>())) @@ -51,6 +59,16 @@ public class FindExtrasTests BaseItem.MediaSourceManager ??= fixture.Create<IMediaSourceManager>(); } + private static Dictionary<string, string> LoadCoreStrings() + { + using var stream = typeof(Emby.Server.Implementations.Library.LibraryManager).Assembly + .GetManifestResourceStream("Emby.Server.Implementations.Localization.Core.en-US.json") + ?? throw new InvalidOperationException("Core localization resource is missing"); + + return JsonSerializer.Deserialize<Dictionary<string, string>>(stream) + ?? throw new InvalidOperationException("Core localization resource is empty"); + } + [Fact] public void FindExtras_SeparateMovieFolder_FindsCorrectExtras() { @@ -132,60 +150,60 @@ public class FindExtrasTests It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/some trailer.mkv", Name = "some trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/behind the scenes", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/behind the scenes/the making of Up.mkv", Name = "the making of Up.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/theme-music", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/theme-music/theme2.mp3", Name = "theme2.mp3", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/extras", It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/extras/Honest Trailer.mkv", Name = "Honest Trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var files = paths.Select(p => new FileSystemMetadata { @@ -289,15 +307,15 @@ public class FindExtrasTests It.IsAny<string[]>(), false, false)) - .Returns(new List<FileSystemMetadata> - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/trailer.jpg", Name = "trailer.jpg", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList(); @@ -306,6 +324,47 @@ public class FindExtrasTests } [Fact] + public void FindExtras_TrailerWithYearInFilename_SetsProductionYearFromFilename() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() + { + FullName = "/movies/Up/trailers/Trailer 1 (2013).mkv", + Name = "Trailer 1 (2013).mkv", + IsDirectory = false + } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).ToList(); + + _fileSystemMock.Verify(); + var trailer = Assert.Single(extras); + Assert.Equal(ExtraType.Trailer, trailer.ExtraType); + Assert.Equal(typeof(Trailer), trailer.GetType()); + Assert.Equal(2013, trailer.ProductionYear); + } + + [Fact] public void FindExtras_SeriesWithTrailers_FindsCorrectExtras() { var owner = new Series { Name = "Dexter", Path = "/series/Dexter" }; @@ -331,4 +390,198 @@ public class FindExtrasTests Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path); Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path); } + + [Fact] + public void FindExtras_SameExtraInSeveralContainers_ReturnsEach() + { + var owner = new Movie { Name = "Skyscraper", Path = "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv" }; + var paths = new List<string> + { + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + // A container is a separate file that plays on its own, so it is a separate extra + Assert.Equal(4, extras.Count); + Assert.Equal("Behind The Scenes", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"]); + Assert.Equal("Trailer", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4"]); + } + + [Fact] + public void FindExtras_SameExtraInSeveralResolutions_ReturnsEach() + { + var owner = new Movie { Name = "Dragon 2", Path = "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv" }; + var paths = new List<string> + { + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(2, extras.Count); + Assert.Equal("Trailer", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"]); + } + + [Fact] + public void FindExtras_NumberedExtras_AreKeptApart() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List<string> + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mp4", + "/movies/Up (2009)/Up (2009)-trailer3.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + Assert.Equal(4, extras.Count); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer.mkv", extras[0].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mkv", extras[1].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mp4", extras[2].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer3.mkv", extras[3].Path); + + // The index in the file name is not the number the extra is given, which counts the + // extras of a type as they are found + Assert.Equal("Trailer", extras[0].Name); + Assert.Equal("Trailer 2", extras[1].Name); + Assert.Equal("Trailer 3", extras[2].Name); + Assert.Equal("Trailer 4", extras[3].Name); + } + + [Fact] + public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List<string> + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv", + "/movies/Up (2009)/Recording the audio-behindthescenes.mkv", + "/movies/Up (2009)/Up (2009)-behindthescenes.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(3, extras.Count); + Assert.Equal("Trailer", extras["/movies/Up (2009)/Up (2009)-trailer.mkv"]); + + // A descriptive file name is a real title and survives, and does not consume a number + Assert.Equal("Recording the audio", extras["/movies/Up (2009)/Recording the audio-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes", extras["/movies/Up (2009)/Up (2009)-behindthescenes.mkv"]); + } + + [Fact] + public void FindExtras_ExtraInOwnFolder_IsNamedAfterItsFile() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Comic-Con Reel.mkv", Name = "Comic-Con Reel.mkv", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + _fileSystemMock.Verify(); + Assert.Equal(2, extras.Count); + Assert.Equal("Teaser", extras["/movies/Up/trailers/Teaser.mkv"]); + Assert.Equal("Comic-Con Reel", extras["/movies/Up/trailers/Comic-Con Reel.mkv"]); + } + + [Fact] + public void FindExtras_DistinctExtrasInSameFolder_AreKeptApart() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny<string[]>(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mkv", Name = "Official.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mp4", Name = "Official.mp4", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + _fileSystemMock.Verify(); + Assert.Equal(3, extras.Count); + Assert.Equal("/movies/Up/trailers/Official.mkv", extras[0].Path); + Assert.Equal("/movies/Up/trailers/Official.mp4", extras[1].Path); + Assert.Equal("/movies/Up/trailers/Teaser.mkv", extras[2].Path); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs new file mode 100644 index 0000000000..31109b2968 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager; + +public sealed class ResolveAlternateVersionTests : IDisposable +{ + private const string PrimaryPath = "/movies/Up/Up.mkv"; + private const string AlternatePath = "/movies/Up/Up - 1080p.mkv"; + + private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager; + private readonly Mock<IItemPersistenceService> _persistenceServiceMock; + private readonly Folder _staleParent; + private readonly ILibraryManager? _previousLibraryManager; + private readonly IMediaSourceManager? _previousMediaSourceManager; + private readonly IItemRepository? _previousItemRepository; + + public ResolveAlternateVersionTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + fixture.Freeze<Mock<IServerConfigurationManager>>() + .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + _persistenceServiceMock = fixture.Freeze<Mock<IItemPersistenceService>>(); + var itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>(); + fixture.Freeze<Mock<IFileSystem>>() + .Setup(f => f.GetFileInfo(It.IsAny<string>())) + .Returns<string>(path => new FileSystemMetadata { FullName = path }); + + _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>() + .Do(s => s.AddParts( + fixture.Create<IEnumerable<IResolverIgnoreRule>>(), + [], + fixture.Create<IEnumerable<IIntroProvider>>(), + fixture.Create<IEnumerable<IBaseItemComparer>>(), + fixture.Create<IEnumerable<ILibraryPostScanTask>>())) + .Create(); + + // BaseItem resolves these through process-wide statics; restored in Dispose. + _previousLibraryManager = BaseItem.LibraryManager; + _previousMediaSourceManager = BaseItem.MediaSourceManager; + _previousItemRepository = BaseItem.ItemRepository; + BaseItem.LibraryManager = _libraryManager; + + var mediaSourceManagerMock = new Mock<IMediaSourceManager>(); + mediaSourceManagerMock.Setup(m => m.GetMediaStreams(It.IsAny<Guid>())).Returns([]); + mediaSourceManagerMock.Setup(m => m.GetMediaAttachments(It.IsAny<Guid>())).Returns([]); + BaseItem.MediaSourceManager = mediaSourceManagerMock.Object; + + // A reloaded listing comes back empty, so a stale entry surviving is visible. + itemRepositoryMock.Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())).Returns([]); + BaseItem.ItemRepository = itemRepositoryMock.Object; + + BaseItem.FileSystem ??= fixture.Create<IFileSystem>(); + BaseItem.MediaSegmentManager ??= fixture.Create<IMediaSegmentManager>(); + BaseItem.ConfigurationManager ??= fixture.Create<IServerConfigurationManager>(); + Video.RecordingsManager ??= fixture.Create<IRecordingsManager>(); + + var primary = new Movie + { + Name = "Up", + Path = PrimaryPath, + LocalAlternateVersions = [AlternatePath], + Id = _libraryManager.GetNewItemId(PrimaryPath, typeof(Movie)) + }; + + _staleParent = new Folder + { + Name = "Up", + Path = "/movies/Up", + Id = _libraryManager.GetNewItemId("/movies/Up", typeof(Folder)) + }; + + var staleAlternate = new Video + { + Name = "Up - 1080p", + Path = AlternatePath, + OwnerId = primary.Id, + ParentId = _staleParent.Id, + Id = _libraryManager.GetNewItemId(AlternatePath, typeof(Video)) + }; + staleAlternate.SetPrimaryVersionId(primary.Id); + + itemRepositoryMock + .Setup(i => i.RetrieveItem(It.IsAny<Guid>())) + .Returns<Guid>(id => id.Equals(primary.Id) ? primary + : id.Equals(staleAlternate.Id) ? staleAlternate + : id.Equals(_staleParent.Id) ? _staleParent + : null!); + + StaleAlternateId = staleAlternate.Id; + } + + private Guid StaleAlternateId { get; } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager!; + BaseItem.MediaSourceManager = _previousMediaSourceManager!; + BaseItem.ItemRepository = _previousItemRepository!; + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsRowWithoutResavingPrimary() + { + // The alternate is stored under the id of the generic Video type while its primary is a Movie. + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + _persistenceServiceMock.Verify( + p => p.DeleteItem(It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 1 && ids[0].Equals(StaleAlternateId))), + Times.Once); + + // Saving the primary is what re-enters this method before the stale row is gone. + _persistenceServiceMock.Verify( + p => p.SaveItems(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [Fact] + public void ResolveAlternateVersion_StaleWrongTypeItem_DropsCachedParentListing() + { + _staleParent.Children = [new Video { Name = "Up - 1080p", Path = AlternatePath }]; + + _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null); + + Assert.Empty(_staleParent.Children); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs new file mode 100644 index 0000000000..6d0c491382 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.ScheduledTasks.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Tasks; +using Moq; +using Xunit; +using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class LibraryManagerScanTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StartScanInBackground_QueuesOnlyWhenIdle(bool scanRunning) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze<Mock<ITaskManager>>(); + var manager = fixture.Create<ServerLibraryManager>(); + typeof(ServerLibraryManager).GetProperty(nameof(ServerLibraryManager.IsScanRunning))!.SetValue(manager, scanRunning); + + await manager.StartScanInBackground().ConfigureAwait(true); + + tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), scanRunning ? Times.Never() : Times.Once()); + tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Never()); + } + + [Fact] + public async Task ValidateMediaLibrary_RestartsScheduledScan() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>(); + configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + var tasks = fixture.Freeze<Mock<ITaskManager>>(); + var manager = fixture.Create<ServerLibraryManager>(); + + await manager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(true); + + tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Once()); + tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), Times.Never()); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs new file mode 100644 index 0000000000..65ec41291d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerSortTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Naming.Common; +using Emby.Server.Implementations.Library; +using Emby.Server.Implementations.Sorting; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; +using BaseItem = MediaBrowser.Controller.Entities.BaseItem; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class LibraryManagerSortTests +{ + [Fact] + public void Sort_UserDependentKey_NullUser_ThrowsArgumentException() + { + var libraryManager = CreateLibraryManager( + new IBaseItemComparer[] { new PlayCountComparer(), new SortNameComparer() }); + + BaseItem[] items = + { + new Audio { Name = "Zulu", SortName = "Zulu", Id = Guid.NewGuid() }, + new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() }, + }; + + Assert.Throws<ArgumentException>(() => libraryManager.Sort( + items, + user: null, + new[] { (ItemSortBy.PlayCount, SortOrder.Descending) }).ToArray()); + } + + [Fact] + public void Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName() + { + var libraryManager = CreateLibraryManager( + new IBaseItemComparer[] { new DateLastMediaAddedComparer(), new SortNameComparer() }); + + BaseItem[] items = + { + MakeFolder("Alpha", new DateTime(2026, 1, 1)), + MakeFolder("Mike", new DateTime(2025, 1, 1)), + MakeFolder("Zulu", new DateTime(2024, 1, 1)) + }; + + var sorted = libraryManager.Sort( + items, + user: null, + new[] { (ItemSortBy.DateLastContentAdded, SortOrder.Descending) }).ToArray(); + + Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name)); + } + + private static Folder MakeFolder(string name, DateTime dateLastMediaAdded) + => new() { Name = name, Id = Guid.NewGuid(), DateLastMediaAdded = dateLastMediaAdded }; + + private static Emby.Server.Implementations.Library.LibraryManager CreateLibraryManager(IReadOnlyCollection<IBaseItemComparer> comparers) + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + fixture.Register(() => new NamingOptions()); + var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>(); + configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data"); + BaseItem.ConfigurationManager ??= configMock.Object; + var itemRepository = fixture.Freeze<Mock<IItemRepository>>(); + itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null); + var fileSystemMock = fixture.Freeze<Mock<IFileSystem>>(); + fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path }); + + return fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts( + fixture.Create<IEnumerable<IResolverIgnoreRule>>(), + fixture.Create<IEnumerable<IItemResolver>>(), + fixture.Create<IEnumerable<IIntroProvider>>(), + comparers, + fixture.Create<IEnumerable<ILibraryPostScanTask>>())) + .Create(); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index c80f899498..131cb23fa4 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -7,7 +7,9 @@ using Castle.Components.DictionaryAdapter; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaSegments; @@ -149,6 +151,73 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex); } + [Theory] + // A remembered full track must not survive a switch to "only forced" (it falls through to + // the forced track here); a remembered forced track and "off" still must. + [InlineData(SubtitlePlaybackMode.OnlyForced, 2, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, 3, 3)] + [InlineData(SubtitlePlaybackMode.OnlyForced, -1, -1)] + [InlineData(SubtitlePlaybackMode.Default, 2, 2)] + [InlineData(SubtitlePlaybackMode.Always, 2, 2)] + [InlineData(SubtitlePlaybackMode.Smart, 2, 2)] + [InlineData(SubtitlePlaybackMode.None, 2, null)] + public void SetDefaultSubtitleStreamIndex_RememberedSelection_RespectsSubtitleMode( + SubtitlePlaybackMode mode, + int rememberedIndex, + int? expectedIndex) + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = rememberedIndex }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false }, + new() { Index = 3, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = false, IsForced = true } + } + }; + + _user.SubtitleMode = mode; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Equal(expectedIndex, mediaInfo.DefaultSubtitleStreamIndex); + } + + [Fact] + public void SetDefaultSubtitleStreamIndex_OnlyForcedRemembersFullTrackWithNoForcedStream_SelectsNothing() + { + _mockUserDataManager + .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = 2 }); + + var mediaInfo = new MediaSourceInfo + { + MediaStreams = new MediaStream[] + { + new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true }, + new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true }, + new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false } + } + }; + + _user.SubtitleMode = SubtitlePlaybackMode.OnlyForced; + _user.SubtitleLanguagePreference = string.Empty; + _user.RememberSubtitleSelections = true; + _user.AudioLanguagePreference = string.Empty; + + _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); + + Assert.Null(mediaInfo.DefaultSubtitleStreamIndex); + } + [Fact] public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion() { @@ -195,6 +264,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library } [Fact] + public void GetStaticMediaSources_ItemWithoutMediaSources_ThrowsArgumentException() + { + // A container queued by mistake is a bad request, not a server fault. + Assert.Throws<ArgumentException>( + () => _mediaSourceManager.GetStaticMediaSources(new MusicArtist { Id = Guid.NewGuid() }, false, _user)); + } + + [Fact] public void GetStaticMediaSources_NoUser_DoesNotTouchUserData() { var (primary, _, _) = SetupVersionGroup(); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs new file mode 100644 index 0000000000..421671b520 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Data; +using Emby.Server.Implementations.Library.SimilarItems; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Tests.Item; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Covers how <see cref="MovieSimilarItemsProvider"/> treats alternate versions: they share their +/// primary's genres, tags, studios and people, so they score like it and must not be offered as +/// something similar - neither as another copy of a recommendation nor as a match for the source. +/// </summary> +public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture +{ + private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private readonly MovieSimilarItemsProvider _provider; + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly User _user = new("test", "auth-provider", "reset-provider"); + private readonly string _movieTypeName; + private readonly string _folderTypeName; + + private readonly Guid _source = Guid.NewGuid(); + private readonly Guid _sourceAlternate = Guid.NewGuid(); + private readonly Guid _similar = Guid.NewGuid(); + private readonly Guid _similarAlternate = Guid.NewGuid(); + private readonly Guid _unrelated = Guid.NewGuid(); + + // A second scenario, in two libraries and on a genre of its own, for the group whose primary the + // user may not be able to reach at all. + private readonly Guid _crossSource = Guid.NewGuid(); + private readonly Guid _crossLibraryPrimary = Guid.NewGuid(); + private readonly Guid _crossLibraryVersion = Guid.NewGuid(); + private readonly Guid _sameLibraryPrimary = Guid.NewGuid(); + private readonly Guid _sameLibraryVersion = Guid.NewGuid(); + + public MovieSimilarItemsProviderTests() + { + var itemTypeLookup = new ItemTypeLookup(); + _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!; + _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!; + + using (var context = CreateDbContext()) + { + Seed(context); + } + + var serverConfigurationManager = new Mock<IServerConfigurationManager>(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + _provider = new MovieSimilarItemsProvider( + CreateDbContextFactory(), + CreateBaseItemRepository(itemTypeLookup), + serverConfigurationManager.Object, + _libraryManager.Object); + } + + [Fact] + public async Task GetSimilarItems_ReturnsThePrimaryAndNeitherVersionOfTheSource() + { + var items = await GetSimilarItemsAsync().ConfigureAwait(true); + + Assert.Equal([_similar], items); + } + + [Fact] + public async Task GetSimilarItems_DoesNotOfferAnAlternateVersionOfAMatch() + { + var items = await GetSimilarItemsAsync().ConfigureAwait(true); + + Assert.DoesNotContain(_similarAlternate, items); + } + + [Fact] + public async Task GetSimilarItems_DoesNotOfferTheSourcesOwnOtherVersion() + { + var items = await GetSimilarItemsAsync().ConfigureAwait(true); + + Assert.DoesNotContain(_sourceAlternate, items); + } + + [Fact] + public async Task GetSimilarItems_UserWithoutThePrimarysLibrary_OffersTheVersion() + { + // The user may only open the library the 1080p version is in, so its primary is out of reach + // and the version is all that is left to stand in for the group. + RestrictUserTo(_movieLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_crossLibraryVersion, items); + Assert.DoesNotContain(_crossLibraryPrimary, items); + } + + [Fact] + public async Task GetSimilarItems_UserWithBothLibraries_OffersThePrimaryOfTheGroupOnce() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_crossLibraryPrimary, items); + Assert.DoesNotContain(_crossLibraryVersion, items); + } + + [Fact] + public async Task GetSimilarItems_GroupMergedInsideOneLibrary_StillOffersOnlyThePrimary() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true); + + Assert.Contains(_sameLibraryPrimary, items); + Assert.DoesNotContain(_sameLibraryVersion, items); + } + + private void RestrictUserTo(params Guid[] libraryIds) + { + _libraryManager + .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>())) + .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds); + } + + private async Task<List<Guid>> GetSimilarItemsAsync(Guid? sourceId = null) + { + var results = await _provider.GetSimilarItemsAsync( + new Movie { Id = sourceId ?? _source, Name = "Source" }, + new SimilarItemsQuery { User = _user, Limit = 10, DtoOptions = new DtoOptions() }, + CancellationToken.None).ConfigureAwait(false); + + return results.Select(i => i.Id).ToList(); + } + + private void Seed(JellyfinDbContext context) + { + // One shared genre, so every movie but the unrelated one scores against the source. + var shared = CreateItemValue("Action", "action"); + var other = CreateItemValue("Comedy", "comedy"); + + var source = AddMovie(context, _source, "Source", primaryVersionId: null); + var sourceAlternate = AddMovie(context, _sourceAlternate, "Source 4K", primaryVersionId: _source); + var similar = AddMovie(context, _similar, "Similar", primaryVersionId: null); + var similarAlternate = AddMovie(context, _similarAlternate, "Similar 4K", primaryVersionId: _similar); + var unrelated = AddMovie(context, _unrelated, "Unrelated", primaryVersionId: null); + + // The second scenario scores on a genre of its own, so it stays out of the results above. + var crossLibrary = CreateItemValue("Science Fiction", "science fiction"); + + AddLibrary(context, _movieLibraryId, "Movies"); + AddLibrary(context, _movie4KLibraryId, "Movies-4K"); + + var crossSource = AddMovie(context, _crossSource, "Cross Source", primaryVersionId: null, libraryId: _movieLibraryId); + + // The 4K version heads the group and lives in a library of its own. + var crossLibraryPrimary = AddMovie(context, _crossLibraryPrimary, "Coco 4K", primaryVersionId: null, libraryId: _movie4KLibraryId); + var crossLibraryVersion = AddMovie(context, _crossLibraryVersion, "Coco", primaryVersionId: _crossLibraryPrimary, libraryId: _movieLibraryId); + + // A group merged inside one library, as a control. + var sameLibraryPrimary = AddMovie(context, _sameLibraryPrimary, "Up 4K", primaryVersionId: null, libraryId: _movieLibraryId); + var sameLibraryVersion = AddMovie(context, _sameLibraryVersion, "Up", primaryVersionId: _sameLibraryPrimary, libraryId: _movieLibraryId); + + context.Users.Add(_user); + context.ItemValues.AddRange(shared, other, crossLibrary); + context.ItemValuesMap.AddRange( + CreateMap(source, shared), + CreateMap(sourceAlternate, shared), + CreateMap(similar, shared), + CreateMap(similarAlternate, shared), + CreateMap(unrelated, other), + CreateMap(crossSource, crossLibrary), + CreateMap(crossLibraryPrimary, crossLibrary), + CreateMap(crossLibraryVersion, crossLibrary), + CreateMap(sameLibraryPrimary, crossLibrary), + CreateMap(sameLibraryVersion, crossLibrary)); + + context.SaveChanges(); + } + + private void AddLibrary(JellyfinDbContext context, Guid id, string name) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = _folderTypeName, + Name = name, + Path = "/" + name, + IsFolder = true + }); + } + + private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId, Guid? libraryId = null) + { + var item = new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + SortName = name, + ParentId = libraryId, + TopParentId = libraryId, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false, + // An alternate presents under its primary's key, which is what collapses the group in listings. + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"), + PrimaryVersionId = primaryVersionId + }; + + context.BaseItems.Add(item); + return item; + } + + private static ItemValue CreateItemValue(string value, string cleanValue) + => new() + { + ItemValueId = Guid.NewGuid(), + Type = ItemValueType.Genre, + Value = value, + CleanValue = cleanValue + }; + + private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue) + => new() + { + ItemId = item.Id, + ItemValueId = itemValue.ItemValueId, + Item = item, + ItemValue = itemValue + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index 650d67b195..e65bc1d31f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library { [Theory] [InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son", "imdbid", null)] [InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")] + [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")] [InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")] [InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")] [InlineData("Superman: Red Son [providera id=4]", "providera id", "4")] [InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")] + [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")] [InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")] - [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")] [InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")] [InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")] [InlineData("[tmdbid=618355]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]", "tmdbid", "618355")] [InlineData("{tmdbid=618355}", "tmdbid", "618355")] + [InlineData("{tmdb=618355}", "tmdbid", "618355")] [InlineData("(tmdbid=618355)", "tmdbid", "618355")] + [InlineData("(tmdb=618355)", "tmdbid", "618355")] [InlineData("[tmdbid-618355]", "tmdbid", "618355")] + [InlineData("[tmdb-618355]", "tmdbid", "618355")] [InlineData("{tmdbid-618355)", "tmdbid", null)] + [InlineData("{tmdb-618355)", "tmdbid", null)] [InlineData("[tmdbid-618355}", "tmdbid", null)] + [InlineData("[tmdb-618355}", "tmdbid", null)] [InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")] + [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")] [InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")] [InlineData("tmdbid=618355]", "tmdbid", null)] + [InlineData("tmdb=618355]", "tmdbid", null)] [InlineData("[tmdbid=618355", "tmdbid", null)] + [InlineData("[tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=618355", "tmdbid", null)] + [InlineData("tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=", "tmdbid", null)] + [InlineData("tmdb=", "tmdbid", null)] [InlineData("tmdbid", "tmdbid", null)] + [InlineData("tmdb", "tmdbid", null)] + [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")] [InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)] + [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)] [InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)] + [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)] [InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")] [InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)] + [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)] [InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)] + [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)] [InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")] public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult) { Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute)); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs new file mode 100644 index 0000000000..30f7bed208 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Library.Validators; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Tests for how the people validator decides which credits need a person item and which person items +/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves +/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id. +/// </summary> +public class PeopleValidatorPartitionTests +{ + // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and + // from nothing else. The property that matters is that it does not depend on the item's own name. + private static Guid PersonId(string creditName) + { +#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms + var hash = System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant())); +#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms + return new Guid(hash); + } + + [Fact] + public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing() + { + // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider + // that refreshed it. Nothing about the library changed, so nothing should be created or deleted. + var credits = new[] { "AURORA" }; + var existing = new HashSet<Guid> { PersonId("AURORA") }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + + Assert.Empty(newNames); + Assert.Empty(deadIds); + } + + [Theory] + // Every shape of rename seen in the wild on a real library. + [InlineData("AURORA")] + [InlineData("Amir AboulEla")] + [InlineData("Miguel Ángel Fuentes")] + [InlineData("a‐ha")] + [InlineData("윤현민")] + public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName) + { + var existing = new HashSet<Guid> { PersonId(creditName) }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing); + + Assert.Empty(newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated() + { + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId( + ["Wanted Person"], + PersonId, + new HashSet<Guid>()); + + Assert.Equal(["Wanted Person"], newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead() + { + var orphan = PersonId("Nobody Credits Me"); + var existing = new HashSet<Guid> { PersonId("Credited"), orphan }; + + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing); + + Assert.Empty(newNames); + Assert.Equal([orphan], deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem() + { + // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly + // one of them should create the item and neither should end up dead. + var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId( + ["AURORA", "Aurora", "aurora"], + PersonId, + new HashSet<Guid>()); + + Assert.Single(newNames); + Assert.Empty(deadIds); + } + + [Fact] + public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther() + { + // The churn showed up as a run that never settled, so drive two rounds: whatever round one + // created must leave round two with nothing to do. + string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"]; + var existing = new HashSet<Guid>(); + + var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + Assert.Equal(3, firstNames.Count); + Assert.Empty(firstDead); + + foreach (var created in firstNames) + { + existing.Add(PersonId(created)); + } + + var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing); + + Assert.Empty(secondNames); + Assert.Empty(secondDead); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs index 133a3f7d47..67d924d152 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs @@ -21,7 +21,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library { var localizationMock = new Mock<ILocalizationManager>(); localizationMock - .Setup(l => l.GetLocalizedString(It.IsAny<string>())) + .Setup(l => l.GetServerLocalizedString(It.IsAny<string>())) .Returns("Season {0}"); _resolver = new SeasonResolver( @@ -62,6 +62,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.Equal(expectedId, actualId); } + [Theory] + [InlineData("/media/Show/Season 01 [anidbid=11111]", "AniDB", "11111")] + [InlineData("/media/Show/Season 01 [anidbid-11111]", "AniDB", "11111")] + [InlineData("/media/Show/Season 02 [anilistid=22222]", "AniList", "22222")] + [InlineData("/media/Show/Season 02 (anilistid=22222)", "AniList", "22222")] + [InlineData("/media/Show/Season 03 [anisearchid=33333]", "AniSearch", "33333")] + public void Resolve_SeasonFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId) + { + var series = new Series { Path = "/media/Show" }; + + var args = new MediaBrowser.Controller.Library.ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + LibraryOptions = new LibraryOptions(), + FileInfo = new FileSystemMetadata + { + FullName = path, + IsDirectory = true + } + }; + + var season = _resolver.Resolve(args); + + Assert.NotNull(season); + Assert.True(season.TryGetProviderId(providerKey, out var actualId)); + Assert.Equal(expectedId, actualId); + } + [Fact] public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll() { @@ -140,6 +170,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _)); Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _)); Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _)); + Assert.False(season.TryGetProviderId("AniDB", out _)); + Assert.False(season.TryGetProviderId("AniList", out _)); + Assert.False(season.TryGetProviderId("AniSearch", out _)); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs new file mode 100644 index 0000000000..5aa770b9b3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Data; +using Emby.Server.Implementations.Library.Search; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using Jellyfin.Server.Implementations.Tests.Item; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +/// <summary> +/// Covers what <see cref="SqlSearchProvider"/> returns for a version group merged across two +/// libraries: the primary represents the group wherever it is visible, and the version stands in +/// for it for a user who cannot open the library the primary lives in. +/// </summary> +public sealed class SqlSearchProviderTests : SqliteDbTestFixture +{ + private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static readonly Guid _primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + private static readonly Guid _versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + private readonly SqlSearchProvider _provider; + private readonly Mock<ILibraryManager> _libraryManager = new(); + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + public SqlSearchProviderTests() + { + var itemTypeLookup = new ItemTypeLookup(); + var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!; + var folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!; + + using (var context = CreateDbContext()) + { + context.Users.Add(_user); + context.BaseItems.Add(CreateLibrary(_movieLibraryId, folderTypeName, "Movies", "/movies")); + context.BaseItems.Add(CreateLibrary(_movie4KLibraryId, folderTypeName, "Movies-4K", "/movies-4k")); + context.BaseItems.Add(CreateMovie(_primaryId, movieTypeName, _movie4KLibraryId, null)); + context.BaseItems.Add(CreateMovie(_versionId, movieTypeName, _movieLibraryId, _primaryId)); + context.SaveChanges(); + } + + var userManager = new Mock<IUserManager>(); + userManager.Setup(u => u.GetUserById(_user.Id)).Returns(_user); + + _provider = new SqlSearchProvider( + CreateDbContextFactory(), + itemTypeLookup, + _libraryManager.Object, + userManager.Object, + CreateBaseItemRepository(itemTypeLookup)); + } + + [Fact] + public async Task SearchAsync_UserWithoutThePrimarysLibrary_FindsTheVersion() + { + RestrictUserTo(_movieLibraryId); + + var hits = await SearchAsync().ConfigureAwait(true); + + Assert.Equal([_versionId], hits); + } + + [Fact] + public async Task SearchAsync_UserWithBothLibraries_FindsThePrimaryOnce() + { + RestrictUserTo(_movieLibraryId, _movie4KLibraryId); + + var hits = await SearchAsync().ConfigureAwait(true); + + Assert.Equal([_primaryId], hits); + } + + private void RestrictUserTo(params Guid[] libraryIds) + { + _libraryManager + .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>())) + .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds); + } + + private async Task<List<Guid>> SearchAsync() + { + var results = await _provider.SearchAsync( + new SearchProviderQuery { SearchTerm = "coco", UserId = _user.Id, Limit = 10 }, + CancellationToken.None).ConfigureAwait(false); + + return results.Select(r => r.ItemId).ToList(); + } + + private static BaseItemEntity CreateLibrary(Guid id, string typeName, string name, string path) + => new() + { + Id = id, + Type = typeName, + Name = name, + Path = path, + IsFolder = true + }; + + private static BaseItemEntity CreateMovie(Guid id, string typeName, Guid libraryId, Guid? primaryVersionId) + => new() + { + Id = id, + Type = typeName, + Name = "Coco", + CleanName = "coco", + SortName = "Coco", + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false, + ParentId = libraryId, + TopParentId = libraryId, + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"), + PrimaryVersionId = primaryVersionId + }; +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs new file mode 100644 index 0000000000..ba3127bc08 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.Library; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using AudioBook = MediaBrowser.Controller.Entities.AudioBook; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public sealed class UserDataManagerTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserDataManager _userDataManager; + private readonly User _user; + + public UserDataManagerTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + var config = new Mock<IServerConfigurationManager>(); + config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration()); + + _userDataManager = new UserDataManager(config.Object, factory.Object); + _user = new User("user", "auth-provider", "reset-provider") + { + Id = Guid.NewGuid() + }; + } + + public void Dispose() + { + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + private AudioBook CreateAudioBook() + { + // GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"] + return new AudioBook + { + Id = Guid.NewGuid(), + Name = "Book Title", + Album = "Series", + AlbumArtists = new[] { "Author" }, + IndexNumber = 1 + }; + } + + private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks) + { + return new UserData + { + ItemId = item.Id, + Item = null, + UserId = _user.Id, + User = null, + CustomDataKey = key, + PlaybackPositionTicks = positionTicks + }; + } + + [Fact] + public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + // the retired-key row comes first to ensure selection is by key, not row order + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(currentKey, userData.Key); + Assert.Equal(222, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow() + { + var item = CreateAudioBook(); + var idKey = item.GetUserDataKeys()[1]; + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), + CreateUserDataRow(item, idKey, 333) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(idKey, userData.Key); + Assert.Equal(333, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow() + { + var item = CreateAudioBook(); + + item.UserData = new List<UserData> + { + CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(111, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey() + { + var item = CreateAudioBook(); + item.UserData = new List<UserData>(); + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(item.GetUserDataKeys()[0], userData.Key); + Assert.Equal(0, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_RowsForOtherUsers_AreIgnored() + { + var item = CreateAudioBook(); + var currentKey = item.GetUserDataKeys()[0]; + + var otherUserRow = CreateUserDataRow(item, currentKey, 999); + otherUserRow.UserId = Guid.NewGuid(); + + item.UserData = new List<UserData> + { + otherUserRow, + CreateUserDataRow(item, currentKey, 222) + }; + + var userData = _userDataManager.GetUserData(_user, item); + + Assert.NotNull(userData); + Assert.Equal(222, userData.PlaybackPositionTicks); + } + + [Fact] + public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder() + { + // no preloaded navigation data, so the batch takes the database fallback + var fossilItem = CreateAudioBook(); + var retiredItem = CreateAudioBook(); + + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(_user); + ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! }); + ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! }); + + // the stale id-key row is inserted first so selection by row order would return it + ctx.UserData.AddRange( + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111), + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222), + CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333)); + ctx.SaveChanges(); + } + + var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user); + + Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks); + Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks); + } + + [Fact] + public void GetUserData_NullUser_ThrowsArgumentNullException() + { + var item = CreateAudioBook(); + Assert.Throws<ArgumentNullException>(() => _userDataManager.GetUserData(null!, item)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index bdb726f06d..93014e7244 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -119,6 +119,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(code, culture.ThreeLetterISOLanguageName); } + [Theory] + [InlineData("ell", "Greek")] // Comma truncation + [InlineData("nld", "Dutch")] // Semicolon truncation + [InlineData("ron", "Romanian")] // Semicolon truncation, multiple + [InlineData("eng", "English")] // No truncation + [InlineData("zh-CN", "Chinese (Simplified)")] // No truncation, with parentheses + public async Task GetLanguageDisplayName_DelimitedName_ReturnsTruncatedName(string language, string expected) + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + await localizationManager.LoadAll(); + + var result = localizationManager.GetLanguageDisplayName(language); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("xyz")] + public async Task GetLanguageDisplayName_InvalidInput_ReturnsNull(string? language) + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + await localizationManager.LoadAll(); + + var result = localizationManager.GetLanguageDisplayName(language!); + Assert.Null(result); + } + [Fact] public async Task GetParentalRatings_Default_Success() { @@ -165,6 +199,16 @@ namespace Jellyfin.Server.Implementations.Tests.Localization [InlineData("Rated: R", "US", 17, 0)] [InlineData("Rated R", "US", 17, 0)] [InlineData(" PG-13 ", "US", 13, 0)] + [InlineData("T", "IT", 0, null)] + [InlineData("VM6", "IT", 6, null)] + [InlineData("VM12", "IT", 12, null)] + [InlineData("VM14", "IT", 14, null)] + [InlineData("VM18", "IT", 18, null)] + [InlineData("IT-VM14", "IT", 14, null)] // TMDB style country prefix + [InlineData("IT-VM18", "IT", 18, null)] + [InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive + [InlineData("VM 18", "IT", 18, null)] + [InlineData("Vietato ai minori di 18 anni", "IT", 18, null)] public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) { var localizationManager = Setup(new ServerConfiguration() @@ -179,6 +223,30 @@ namespace Jellyfin.Server.Implementations.Tests.Localization } [Theory] + // Rating strings are stored mixed-case in the *.json rating systems and must match regardless of casing + [InlineData("btl", "se", 0, null)] // Direct lookup, lowercase of "Btl" + [InlineData("BARNTILLÅTEN", "se", 0, null)] // Direct lookup, uppercase incl. diacritics + [InlineData("SE-BTL", "se", 0, null)] // Country prefix stripped against the configured country + [InlineData("SE-BTL", "us", 0, null)] // Country prefix resolved via the separator fallback + [InlineData("Från 7 År", "se", 7, null)] // Diacritic casing (json has "Från 7 år") + [InlineData("SE-Från 7 År", "us", 7, null)] // Same, via the separator fallback + [InlineData("fsk-16", "de", 16, null)] // Not Sweden specific: lowercase of "FSK-16" + public async Task GetRatingScore_IsCaseInsensitive_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Theory] [InlineData("0", 0, null)] [InlineData("1", 1, null)] [InlineData("6", 6, null)] @@ -207,6 +275,51 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Null(localizationManager.GetRatingScore("unrated")); Assert.Null(localizationManager.GetRatingScore("Not Rated")); Assert.Null(localizationManager.GetRatingScore("n/a")); + Assert.Null(localizationManager.GetRatingScore("N/A")); + Assert.Null(localizationManager.GetRatingScore(" n/a ")); + } + + [Theory] + // "NR" and "UR" are rating strings of some systems, so they must stay unrated when listed alongside others + [InlineData("NR / R", 17, 0)] + [InlineData("unrated / R", 17, 0)] + [InlineData("R / NR", 17, 0)] + public async Task GetRatingLevel_SkipsUnratedListEntries_Success(string value, int? expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "us" }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Theory] + // Ratings that contain a '/' themselves must not be split into a list of ratings + [InlineData("M/3", "pt", 3, null)] + [InlineData("M/12", "pt", 12, null)] + [InlineData("M/18", "pt", 18, null)] + [InlineData("PT-M/12", "pt", 12, null)] // TMDB style country prefix + [InlineData("M/12", "us", 12, null)] // Resolved through the all-systems fallback + [InlineData("U/A 13+", "in", 13, null)] + [InlineData("7/i", "es", 11, null)] + [InlineData("7/i/fig", "es", 11, null)] + [InlineData("18/fig", "es", 18, null)] + public async Task GetRatingScore_RatingContainingSlash_IsNotSplit(string value, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(value); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); } [Theory] @@ -315,7 +428,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization }); var translated = localizationManager.GetLocalizedString("Artists", "de"); - Assert.Equal("Interpreten", translated); + Assert.Equal("Künstler", translated); } [Fact] @@ -372,7 +485,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization { CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr"); var translated = localizationManager.GetServerLocalizedString("Artists"); - Assert.Equal("Interpreten", translated); + Assert.Equal("Künstler", translated); } finally { @@ -393,7 +506,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization { CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); var translated = localizationManager.GetLocalizedString("Artists"); - Assert.Equal("Interpreten", translated); + Assert.Equal("Künstler", translated); } finally { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index ede9e61536..ee41b968e1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -240,6 +240,28 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins } [Fact] + public async Task PopulateManifest_ExistingImage_IsNotDownloaded() + { + const string ImageContent = "not really a png"; + + var packageInfo = GenerateTestPackage(); + packageInfo.ImageUrl = "https://example.org/some-plugin.png"; + + var imagePath = Path.Combine(_pluginPath, "some-plugin.png"); + await File.WriteAllTextAsync(imagePath, ImageContent, TestContext.Current.CancellationToken); + + // The application host is null, so attempting to download the image would throw. + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, null!, new Version(1, 0)); + + Assert.True(await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active)); + + var result = pluginManager.LoadManifest(_pluginPath).Manifest; + + Assert.Equal(imagePath, result.ImagePath); + Assert.Equal(ImageContent, await File.ReadAllTextAsync(imagePath, TestContext.Current.CancellationToken)); + } + + [Fact] public async Task PopulateManifest_ExistingMetafileMismatchedIds_Status_Malfunctioned() { var packageInfo = GenerateTestPackage(); @@ -293,7 +315,84 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Assert.Equal(packageInfo.Versions[0].Version, result.Version); } - private PackageInfo GenerateTestPackage() + [Fact] + public async Task DisablePlugin_CatalogRefresh_StaysDisabled() + { + var pluginRoot = Path.Combine(_tempPath, "plugins"); + var pluginDir = CreateTestPlugin(pluginRoot, "Disable Me", PluginStatus.Active); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0)); + var plugin = Assert.Single(pluginManager.Plugins); + + pluginManager.DisablePlugin(plugin); + + Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status); + + // The web shows that a restart is required, but the persisted state must not change. + Assert.Equal(PluginStatus.Restart, plugin.GetPluginInfo().Status); + Assert.Equal(PluginStatus.Disabled, plugin.Manifest.Status); + Assert.True(plugin.Manifest.AutoUpdate); + + // Every catalog fetch rewrites the manifests of installed plugins from the in-memory status. + var packageInfo = GenerateTestPackage(plugin.Id); + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), pluginDir, plugin.Manifest.Status); + + Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status); + } + + [Fact] + public void Constructor_DisabledPluginSortingBeforeEnabledPlugin_IsNotDeleted() + { + var pluginRoot = Path.Combine(_tempPath, "plugins"); + var disabledDir = CreateTestPlugin(pluginRoot, "AAA Disabled", PluginStatus.Disabled); + CreateTestPlugin(pluginRoot, "ZZZ Active", PluginStatus.Active); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0)); + + Assert.True(Directory.Exists(disabledDir)); + Assert.Contains(pluginManager.Plugins, p => string.Equals(p.Name, "AAA Disabled", StringComparison.Ordinal)); + } + + [Fact] + public void LoadAssemblies_DisabledPluginWithSupersededVersion_DoesNotRevertToOldVersion() + { + var pluginRoot = Path.Combine(_tempPath, "plugins"); + var id = Guid.NewGuid(); + var oldDir = CreateTestPlugin(pluginRoot, "Two Versions", PluginStatus.Superseded, new Version(1, 0), id); + var newDir = CreateTestPlugin(pluginRoot, "Two Versions_2.0", PluginStatus.Disabled, new Version(2, 0), id, "Two Versions"); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0)); + + Assert.Empty(pluginManager.LoadAssemblies()); + + // Neither version may be touched: the old one stays superseded instead of being loaded + // as a stand-in for the version the user disabled. + Assert.Equal(PluginStatus.Superseded, pluginManager.LoadManifest(oldDir).Manifest.Status); + Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(newDir).Manifest.Status); + } + + private string CreateTestPlugin(string root, string folderName, PluginStatus status, Version? version = null, Guid? id = null, string? name = null) + { + var dir = Path.Combine(root, folderName); + Directory.CreateDirectory(dir); + FileHelper.CreateEmpty(Path.Combine(dir, "some.dll")); + + var manifest = new PluginManifest + { + Id = id ?? Guid.NewGuid(), + Name = name ?? folderName, + Status = status, + AutoUpdate = true, + TargetAbi = "1.0", + Version = (version ?? new Version(1, 0)).ToString() + }; + + File.WriteAllText(Path.Combine(dir, "meta.json"), JsonSerializer.Serialize(manifest, _options)); + + return dir; + } + + private PackageInfo GenerateTestPackage(Guid? id = null) { var fixture = new Fixture(); fixture.Customize<PackageInfo>(c => c.Without(x => x.Versions).Without(x => x.ImageUrl)); @@ -305,6 +404,10 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins var packageInfo = fixture.Create<PackageInfo>(); packageInfo.Versions = new[] { versionInfo }; + if (id.HasValue) + { + packageInfo.Id = id.Value; + } return packageInfo; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs new file mode 100644 index 0000000000..7722707cbe --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class IdlePlaybackTests +{ + [Theory] + [InlineData(null, null)] + [InlineData(123456789L, 123456789L)] + public async Task CheckForIdlePlayback_StopsAtLastClientReportedPosition(long? clientPositionTicks, long? expectedPositionTicks) + { + var playbackStopped = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously); + var eventManager = new Mock<IEventManager>(); + eventManager + .Setup(manager => manager.PublishAsync(It.IsAny<PlaybackStopEventArgs>())) + .Callback<PlaybackStopEventArgs>(eventArgs => playbackStopped.TrySetResult(eventArgs.PlaybackPositionTicks)) + .Returns(Task.CompletedTask); + await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + eventManager.Object, + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IUserManager>(), + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + var session = await sessionManager.LogSessionActivity( + "Test Client", + "1.0.0", + "test-device", + "Test Device", + "127.0.0.1", + null); + session.NowPlayingItem = new BaseItemDto + { + Id = Guid.NewGuid(), + Name = "Test Item" + }; + session.PlayState.PositionTicks = 987654321; + + if (clientPositionTicks.HasValue) + { + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = clientPositionTicks + }); + session.StopAutomaticProgress(); + } + + var idlePlaybackCallback = typeof(Emby.Server.Implementations.Session.SessionManager) + .GetMethod("CheckForIdlePlayback", BindingFlags.Instance | BindingFlags.NonPublic)!; + idlePlaybackCallback.Invoke(sessionManager, new object?[] { null }); + + var stoppedPositionTicks = await playbackStopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(expectedPositionTicks, stoppedPositionTicks); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs new file mode 100644 index 0000000000..a07e79baa3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class PlayCommandQueueTests : IDisposable +{ + private readonly ILibraryManager? _previousLibraryManager; + + public PlayCommandQueueTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + } + + /// <summary> + /// A music genre tags its artists as well as their songs, and a by-name artist row is not a + /// folder, so the queue query cannot exclude it. Such an item has no media sources, and a + /// client that reaches it in the queue gets an error instead of the next track. + /// </summary> + /// <returns><placeholder>A <see cref="Task"/> representing the asynchronous unit test.</placeholder></returns> + [Fact] + public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems() + { + var genre = new MusicGenre { Id = Guid.NewGuid(), Name = "Reggaeton" }; + var song = new Audio { Id = Guid.NewGuid(), Name = "Me Porto Bonito" }; + var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "NATTI NATASHA" }; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(i => i.GetItemById(genre.Id)).Returns(genre); + libraryManager + .Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(new List<BaseItem> { artist, song }); + BaseItem.LibraryManager = libraryManager.Object; + + await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + Mock.Of<IEventManager>(), + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + libraryManager.Object, + Mock.Of<IUserManager>(), + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + + var session = await sessionManager.LogSessionActivity("app_name", "0.0.0", "device_id", "device_name", "127.0.0.1", null); + + var command = new PlayRequest + { + ItemIds = new[] { genre.Id }, + PlayCommand = PlayCommand.PlayNow + }; + + await sessionManager.SendPlayCommand(null, session.Id, command, CancellationToken.None); + + Assert.Equal(new[] { song.Id }, command.ItemIds); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + BaseItem.LibraryManager = _previousLibraryManager!; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs new file mode 100644 index 0000000000..c5b8f661b5 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading.Tasks; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class SessionInfoTests +{ + [Fact] + public async Task StartAutomaticProgress_SnapshotsClientReportedPosition() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + var progressInfo = new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 123456789 + }; + + session.StartAutomaticProgress(progressInfo); + + Assert.Equal(progressInfo.PositionTicks, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task AutomaticProgress_AdvancesEstimatedPositionWithoutAdvancingSnapshot() + { + var sessionManager = new Mock<ISessionManager>(); + await using var session = new SessionInfo(sessionManager.Object, NullLogger.Instance); + var automaticProgress = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously); + const long reportedPositionTicks = 123456789; + + sessionManager + .Setup(manager => manager.OnPlaybackProgress(It.IsAny<PlaybackProgressInfo>(), true)) + .Callback<PlaybackProgressInfo, bool>((info, _) => + { + session.PlayState.PositionTicks = info.PositionTicks; + automaticProgress.TrySetResult(info.PositionTicks); + }) + .Returns(Task.CompletedTask); + session.PlayState.PositionTicks = reportedPositionTicks; + + session.StartAutomaticProgress(new PlaybackProgressInfo + { + PositionTicks = reportedPositionTicks + }); + + var estimatedPositionTicks = await automaticProgress.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.StopAutomaticProgress(); + + Assert.Equal(reportedPositionTicks + TimeSpan.TicksPerSecond, estimatedPositionTicks); + Assert.Equal(estimatedPositionTicks, session.PlayState.PositionTicks); + Assert.Equal(reportedPositionTicks, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task StartAutomaticProgress_ReplacesSnapshotOnLaterClientReport() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 123456789 + }); + + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 987654321 + }); + + Assert.Equal(987654321, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task StartAutomaticProgress_PreservesExactPausedPosition() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + var pausedProgress = new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 314159265 + }; + + session.StartAutomaticProgress(pausedProgress); + + Assert.Equal(pausedProgress.PositionTicks, session.LastPlaybackCheckInPositionTicks); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs index a5a67046d1..f803c69af2 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs @@ -1,6 +1,9 @@ using System; +using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -108,4 +113,136 @@ public class SessionManagerTests return data; } + + [Fact] + public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand( + attackerSession.Id, + victimSession.Id, + new MessageCommand { Header = "Custom Message", Text = "test exploit!" }, + CancellationToken.None)); + } + + [Fact] + public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("controller", "default", "default"); + attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var controllingSession = await LogSessionActivity(sessionManager, attacker); + + await sessionManager.SendMessageCommand( + controllingSession.Id, + victimSession.Id, + new MessageCommand { Header = "Custom Message", Text = "hello" }, + CancellationToken.None); + } + + [Fact] + public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + // Client name and device id are attacker controlled, so they must not identify a session on their own. + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.NotEqual(victimSession.Id, attackerSession.Id); + Assert.Equal(victim.Id, victimSession.UserId); + } + + [Fact] + public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser() + { + var attacker = new User("attacker", "default", "default"); + var victim = new User("victim", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id)); + } + + [Fact] + public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator() + { + var admin = new User("admin", "default", "default"); + admin.SetPermission(PermissionKind.IsAdministrator, true); + var guest = new User("guest", "default", "default"); + await using var sessionManager = CreateSessionManager(admin, guest); + + var adminSession = await LogSessionActivity(sessionManager, admin); + + sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id); + + Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id)); + } + + [Fact] + public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id)); + } + + [Fact] + public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities())); + } + + private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users) + { + var userManager = new Mock<IUserManager>(); + foreach (var user in users) + { + userManager.Setup(i => i.GetUserById(user.Id)).Returns(user); + } + + return new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + Mock.Of<IEventManager>(), + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILibraryManager>(), + userManager.Object, + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + } + + // All sessions are logged with the same client and device id on purpose, those values are taken + // from the request headers and are not bound to the access token of the calling user. + private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user) + => sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs new file mode 100644 index 0000000000..05e8a40de1 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Model.SyncPlay; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class PlayQueueManagerTests +{ + private static PlayQueueManager CreateQueue(int itemCount) + { + var items = Enumerable.Range(0, itemCount).Select(_ => Guid.NewGuid()).ToList(); + var queue = new PlayQueueManager(); + queue.SetPlaylist(items); + return queue; + } + + [Fact] + public void RemoveFromPlaylist_PlayingItemAndPrecedingItemRemoved_PicksPreviousItem() + { + var queue = CreateQueue(5); + queue.SetPlayingItemByIndex(3); + + var playlist = queue.GetPlaylist(); + var expectedItemId = playlist[2].ItemId; + var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId }; + var playingItemRemoved = queue.RemoveFromPlaylist(toRemove); + + Assert.True(playingItemRemoved); + Assert.Equal(3, queue.GetPlaylist().Count); + Assert.Equal(1, queue.PlayingItemIndex); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] + public void RemoveFromPlaylist_PlayingItemAndAllPrecedingItemsRemoved_PicksFirstRemainingItem() + { + var queue = CreateQueue(3); + queue.SetPlayingItemByIndex(2); + + var playlist = queue.GetPlaylist(); + var expectedItemId = playlist[1].ItemId; + var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[2].PlaylistItemId }; + var playingItemRemoved = queue.RemoveFromPlaylist(toRemove); + + Assert.True(playingItemRemoved); + Assert.Single(queue.GetPlaylist()); + Assert.Equal(0, queue.PlayingItemIndex); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] + public void RemoveFromPlaylist_AllItemsRemoved_ResetsPlayingItem() + { + var queue = CreateQueue(2); + queue.SetPlayingItemByIndex(1); + + var toRemove = queue.GetPlaylist().Select(item => item.PlaylistItemId).ToList(); + var playingItemRemoved = queue.RemoveFromPlaylist(toRemove); + + Assert.True(playingItemRemoved); + Assert.Empty(queue.GetPlaylist()); + Assert.False(queue.IsItemPlaying()); + Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId()); + } + + [Fact] + public void RemoveFromPlaylist_ShuffleMode_PicksPreviousItem() + { + var queue = CreateQueue(5); + queue.SetShuffleMode(GroupShuffleMode.Shuffle); + queue.SetPlayingItemByIndex(3); + + var playlist = queue.GetPlaylist(); + var expectedItemId = playlist[2].ItemId; + var toRemove = new List<Guid> { playlist[0].PlaylistItemId, playlist[3].PlaylistItemId }; + var playingItemRemoved = queue.RemoveFromPlaylist(toRemove); + + Assert.True(playingItemRemoved); + Assert.Equal(3, queue.GetPlaylist().Count); + Assert.Equal(1, queue.PlayingItemIndex); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] + public void RemoveFromPlaylist_PlayingItemNotRemoved_RestoresPlayingItem() + { + var queue = CreateQueue(3); + queue.SetPlayingItemByIndex(2); + + var playlist = queue.GetPlaylist(); + var expectedItemId = playlist[2].ItemId; + var toRemove = new List<Guid> { playlist[0].PlaylistItemId }; + var playingItemRemoved = queue.RemoveFromPlaylist(toRemove); + + Assert.False(playingItemRemoved); + Assert.Equal(1, queue.PlayingItemIndex); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Theory] + [InlineData(GroupRepeatMode.RepeatNone)] + [InlineData(GroupRepeatMode.RepeatOne)] + [InlineData(GroupRepeatMode.RepeatAll)] + public void Next_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode) + { + var queue = new PlayQueueManager(); + queue.SetRepeatMode(repeatMode); + + Assert.False(queue.Next()); + Assert.False(queue.IsItemPlaying()); + Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId()); + } + + [Theory] + [InlineData(GroupRepeatMode.RepeatNone)] + [InlineData(GroupRepeatMode.RepeatOne)] + [InlineData(GroupRepeatMode.RepeatAll)] + public void Previous_EmptyPlaylist_ReturnsFalse(GroupRepeatMode repeatMode) + { + var queue = new PlayQueueManager(); + queue.SetRepeatMode(repeatMode); + + Assert.False(queue.Previous()); + Assert.False(queue.IsItemPlaying()); + Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId()); + } + + [Theory] + [InlineData(-1)] + [InlineData(2)] + [InlineData(3)] + public void SetPlayingItemByIndex_OutOfBounds_ResetsPlayingItem(int playlistIndex) + { + var queue = CreateQueue(2); + + queue.SetPlayingItemByIndex(playlistIndex); + + Assert.False(queue.IsItemPlaying()); + Assert.Equal(Guid.Empty, queue.GetPlayingItemPlaylistId()); + } + + [Fact] + public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem() + { + var queue = CreateQueue(3); + queue.SetPlayingItemByIndex(1); + var expectedItemId = queue.GetPlayingItemId(); + + queue.SetShuffleMode(GroupShuffleMode.Sorted); + + Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode); + Assert.Equal(1, queue.PlayingItemIndex); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] + public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem() + { + var queue = CreateQueue(5); + queue.SetPlayingItemByIndex(2); + var expectedItemId = queue.GetPlayingItemId(); + + queue.SetShuffleMode(GroupShuffleMode.Shuffle); + queue.SetShuffleMode(GroupShuffleMode.Sorted); + queue.SetShuffleMode(GroupShuffleMode.Sorted); + + Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode); + Assert.Equal(5, queue.GetPlaylist().Count); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } + + [Fact] + public void SetPlayingItemByIndex_InBounds_SetsPlayingItem() + { + var queue = CreateQueue(2); + var expectedItemId = queue.GetPlaylist()[1].ItemId; + + queue.SetPlayingItemByIndex(1); + + Assert.True(queue.IsItemPlaying()); + Assert.Equal(expectedItemId, queue.GetPlayingItemId()); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs new file mode 100644 index 0000000000..ecd8fafe80 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group; +using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class SyncPlayManagerTests +{ + [Fact] + public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter() + { + var harness = new ManagerHarness(); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + Assert.True(harness.Manager.IsUserActive(harness.User.Id)); + + // A client that re-sends Join for the group it is already in must not be counted twice. + harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None); + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter() + { + var harness = new ManagerHarness(); + + harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately() + { + var harness = new ManagerHarness(); + var second = harness.CreateSession("session-2"); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None); + + harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None); + Assert.True(harness.Manager.IsUserActive(harness.User.Id)); + + harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None); + Assert.False(harness.Manager.IsUserActive(harness.User.Id)); + } + + [Fact] + public async Task HandleRequest_GroupWaitsForAMemberThatNeverReportsReady_RecoversOnItsOwn() + { + var harness = new ManagerHarness(groupWaitTimeout: 200); + var second = harness.CreateSession("session-2"); + + var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None); + harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None); + + // Starting playback puts the group behind the ready barrier. + harness.Manager.HandleRequest( + harness.Session, + new PlayGroupRequest(new[] { Guid.NewGuid() }, 0, 0), + CancellationToken.None); + Assert.Equal(GroupStateType.Waiting, harness.Manager.GetGroup(harness.Session, info.GroupId).State); + + // Neither session ever reports ready, so the group has to come out of the wait by itself. + Assert.Equal( + GroupStateType.Playing, + await harness.WaitForState(harness.Session, info.GroupId, GroupStateType.Playing)); + } + + private sealed class ManagerHarness + { + private readonly Mock<ISessionManager> _sessionManager = new(); + + public ManagerHarness(long? groupWaitTimeout = null) + { + var userManager = new Mock<IUserManager>(); + var libraryManager = new Mock<ILibraryManager>(); + + User = new User("tester", "auth-provider", "pwdreset-provider"); + userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User); + + var item = new Mock<BaseItem>(); + item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks; + libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object); + + _sessionManager + .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + _sessionManager + .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>())) + .Returns(Task.CompletedTask); + + Manager = new SyncPlayManager( + NullLoggerFactory.Instance, + userManager.Object, + _sessionManager.Object, + libraryManager.Object) + { + GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout + }; + + Session = CreateSession("session-1"); + } + + public SyncPlayManager Manager { get; } + + public User User { get; } + + public SessionInfo Session { get; } + + public SessionInfo CreateSession(string id) + { + return new SessionInfo(_sessionManager.Object, NullLogger.Instance) + { + Id = id, + UserId = User.Id, + UserName = User.Username + }; + } + + public async Task<GroupStateType> WaitForState(SessionInfo session, Guid groupId, GroupStateType expected) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + GroupStateType state; + while ((state = Manager.GetGroup(session, groupId).State) != expected && DateTime.UtcNow < deadline) + { + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + return state; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs new file mode 100644 index 0000000000..d3cbc9b8be --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class WaitingGroupStateTests +{ + [Fact] + public void Ready_PlayingSessionReportsPositionFromBeforeSeek_IsCorrected() + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(10).Ticks; + group.LastActivity = DateTime.UtcNow; + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + + // One member seeks half an hour in. + state.HandleRequest( + new SeekGroupRequest(TimeSpan.FromMinutes(40).Ticks), + group, + GroupStateType.Playing, + harness.Second, + CancellationToken.None); + + harness.Commands.Clear(); + + // The other member has not applied the seek yet and reports the old position, still playing. + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, TimeSpan.FromMinutes(10).Ticks, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + // It must be seeked into position, not accepted as ready and handed a pause command + // scheduled the length of the seek into the future. + Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek); + Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Pause); + Assert.True(group.IsBuffering(), "session should still be considered buffering"); + } + + [Fact] + public void Ready_PlayingSessionRecoveringFromALongStall_IsNotSeeked() + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(10).Ticks; + group.LastActivity = DateTime.UtcNow; + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + + // The session reports it is buffering. No seek happens, so the group position stays put. + state.HandleRequest( + new BufferGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId), + group, + GroupStateType.Playing, + harness.First, + CancellationToken.None); + + harness.Commands.Clear(); + + // It recovers 45 seconds later, still behind, and must be waited for rather than seeked + // forward past content it already buffered. + var behind = group.PositionTicks - TimeSpan.FromSeconds(45).Ticks; + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, behind, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek); + } + + [Fact] + public void Ready_PlayingSessionSlightlyBehindGroup_IsStillTreatedAsCatchingUp() + { + var harness = new GroupHarness(); + var group = harness.Group; + + // A session that is a couple of seconds behind is genuinely recovering, and the group + // is expected to wait for it rather than seek it around. + group.PositionTicks = TimeSpan.FromMinutes(30).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetBuffering(harness.First, true); + group.SetBuffering(harness.Second, true); + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + harness.Commands.Clear(); + + var clientPosition = group.PositionTicks - TimeSpan.FromSeconds(2).Ticks; + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, clientPosition, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek); + Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Pause); + } + + [Fact] + public void Ready_PausedSessionOutOfPosition_IsStillCorrected() + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(30).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetBuffering(harness.First, true); + group.SetBuffering(harness.Second, true); + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + harness.Commands.Clear(); + + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek); + } + + [Fact] + public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds() + { + var harness = new GroupHarness(); + var group = harness.Group; + + // Both members report a ping well under the default, so the floor is what decides the delay. + group.UpdatePing(harness.First, 10); + group.UpdatePing(harness.Second, 10); + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetBuffering(harness.First, true); + group.SetBuffering(harness.Second, false); + + var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true }; + + var before = DateTime.UtcNow; + state.HandleRequest( + new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId), + group, + GroupStateType.Waiting, + harness.First, + CancellationToken.None); + + // DefaultPing is expressed in milliseconds, so the floor must be converted before being + // compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms) + // and never applies. + var scheduledDelay = group.LastActivity - before; + Assert.True( + scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing), + $"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms"); + } + + [Theory] + [InlineData(4_000_000_000L)] + [InlineData(1_000_000_000_000_000L)] + [InlineData(long.MaxValue)] + [InlineData(-1L)] + public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing) + { + var harness = new GroupHarness(); + var group = harness.Group; + + group.UpdatePing(harness.First, reportedPing); + + Assert.InRange(group.GetHighestPing(), 0, group.MaxPing); + + // The reported ping is scaled into the group's resume point, so an unclamped value either + // pushes playback months out or overflows the arithmetic outright. + var state = new PlayingGroupState(NullLoggerFactory.Instance); + var before = DateTime.UtcNow; + state.HandleRequest( + new UnpauseGroupRequest(), + group, + GroupStateType.Paused, + harness.First, + CancellationToken.None); + + Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task SessionJoined_JoinerNeverReportsReady_GroupResumesWithoutIt() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetState(new PlayingGroupState(NullLoggerFactory.Instance)); + + // A session joins while the group is playing: the group pauses and waits for it. + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + + // The joiner's player aborts and never reports ready. Without a bounded wait the whole + // group stays paused forever. + await harness.WaitForState(GroupStateType.Playing); + + // Late buffer reports from the session that missed the deadline must not drag the group + // back into waiting. + group.HandleRequest( + joiner, + new BufferGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId), + CancellationToken.None); + + Assert.Equal(GroupStateType.Playing, group.GetInfo().State); + } + + [Fact] + public async Task SessionJoined_GroupWasPaused_TimeoutLeavesTheGroupPaused() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + + // The group has been sitting paused for a while before anyone joins. + group.LastActivity = DateTime.UtcNow.AddMinutes(-2); + group.SetState(new PausedGroupState(NullLoggerFactory.Instance)); + + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + + // A group that was paused must not start playing because a member failed to report ready. + await harness.WaitForState(GroupStateType.Paused); + + // Giving up on the joiner must not move the playback position of an already paused group. + Assert.Equal(TimeSpan.FromMinutes(5).Ticks, group.PositionTicks); + + // Every member has to be told the group is no longer waiting. + var recipients = harness.StateUpdates + .Where(update => update.Update.State == GroupStateType.Paused) + .Select(update => update.SessionId) + .ToList(); + Assert.Contains(harness.First.Id, recipients); + Assert.Contains(harness.Second.Id, recipients); + Assert.Contains(joiner.Id, recipients); + } + + [Fact] + public async Task Ready_ReportedBeforeTheDeadline_GroupDoesNotGiveUpOnAnyone() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.PositionTicks = TimeSpan.FromMinutes(5).Ticks; + group.LastActivity = DateTime.UtcNow; + group.SetState(new PlayingGroupState(NullLoggerFactory.Instance)); + + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + + group.HandleRequest( + joiner, + new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId), + CancellationToken.None); + + // Everyone reported ready, so no deadline is left to trip and force a spurious unpause. + Assert.Equal(GroupStateType.Playing, group.GetInfo().State); + Assert.Null(group.GroupWaitDeadline); + + var until = DateTime.UtcNow.AddMilliseconds(3 * 200); + while (DateTime.UtcNow < until) + { + harness.PumpGroupWaitTimeout(); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + Assert.Equal(GroupStateType.Playing, group.GetInfo().State); + } + + [Fact] + public async Task SetPlaylistItem_AfterATimeout_GroupWaitsForEveryoneAgain() + { + var harness = new GroupHarness(groupWaitTimeout: 200); + var group = harness.Group; + + group.LastActivity = DateTime.UtcNow; + group.SetState(new PlayingGroupState(NullLoggerFactory.Instance)); + + var joiner = harness.NewSession("joiner"); + group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None); + await harness.WaitForState(GroupStateType.Playing); + + // Giving up on a session lasts only until the group changes what it is playing. + group.HandleRequest( + harness.First, + new SetPlaylistItemGroupRequest(harness.PlaylistItemId), + CancellationToken.None); + + Assert.Equal(GroupStateType.Waiting, group.GetInfo().State); + Assert.NotNull(group.GroupWaitDeadline); + } + + private sealed class GroupHarness + { + private readonly ISessionManager _sessionManager; + private readonly Guid _userId; + + public GroupHarness(long? groupWaitTimeout = null) + { + var userManager = new Mock<IUserManager>(); + var sessionManager = new Mock<ISessionManager>(); + var libraryManager = new Mock<ILibraryManager>(); + + var user = new User("tester", "auth-provider", "pwdreset-provider"); + userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(user); + + var item = new Mock<BaseItem>(); + item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks; + libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object); + + sessionManager + .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>())) + .Callback<string, SendCommand, CancellationToken>((_, command, _) => Commands.Add(command)) + .Returns(Task.CompletedTask); + + sessionManager + .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>())) + .Callback((string sessionId, GroupUpdate<GroupStateUpdate> update, CancellationToken _) => StateUpdates.Add((sessionId, update.Data))) + .Returns(Task.CompletedTask); + + Group = new SyncPlayGroup( + NullLoggerFactory.Instance, + userManager.Object, + sessionManager.Object, + libraryManager.Object) + { + GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout + }; + + _sessionManager = sessionManager.Object; + _userId = user.Id; + + First = NewSession("first"); + Second = NewSession("second"); + + Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None); + Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None); + Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0); + PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId(); + } + + public SyncPlayGroup Group { get; } + + public List<(string SessionId, GroupStateUpdate Update)> StateUpdates { get; } = new(); + + public SessionInfo First { get; } + + public SessionInfo Second { get; } + + public Guid PlaylistItemId { get; } + + public List<SendCommand> Commands { get; } = new List<SendCommand>(); + + // Mirrors the sweep SyncPlayManager runs on a timer. + public void PumpGroupWaitTimeout() + { + var group = Group; + + // Group lock required as Group is not thread-safe. + lock (group) + { + group.HandleGroupWaitTimeout(CancellationToken.None); + } + } + + public async Task WaitForState(GroupStateType expected) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (Group.GetInfo().State != expected && DateTime.UtcNow < deadline) + { + PumpGroupWaitTimeout(); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + Assert.Equal(expected, Group.GetInfo().State); + } + + public SessionInfo NewSession(string id) + { + return new SessionInfo(_sessionManager, NullLogger.Instance) + { + Id = id, + UserId = _userId, + UserName = id + }; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs new file mode 100644 index 0000000000..5bb5081b60 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Entities.Security; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +public class DeviceAccessHostTests +{ + [Fact] + public async Task OnUserUpdated_LogoutThrows_DoesNotEscapeToThreadPool() + { + var user = new User("test", "default", "default"); + var device = new Device(user.Id, "app", "1.0", "device", "device-id"); + + var deviceManager = new Mock<IDeviceManager>(); + deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>())) + .Returns(new QueryResult<Device>(new[] { device })); + deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false); + + var sessionManager = new Mock<ISessionManager>(); + sessionManager.Setup(s => s.Logout(It.IsAny<Device>())) + .ThrowsAsync(new ObjectDisposedException(nameof(ISessionManager))); + + var userManager = new Mock<IUserManager>(); + var host = new DeviceAccessHost( + userManager.Object, + deviceManager.Object, + sessionManager.Object, + NullLogger<DeviceAccessHost>.Instance); + await host.StartAsync(TestContext.Current.CancellationToken); + + var context = new CapturingSynchronizationContext(); + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user)); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + + Assert.Empty(context.Exceptions); + } + + [Fact] + public async Task OnUserUpdated_DeviceNoLongerAllowed_LogsOutDevice() + { + var user = new User("test", "default", "default"); + var device = new Device(user.Id, "app", "1.0", "device", "device-id"); + + var deviceManager = new Mock<IDeviceManager>(); + deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>())) + .Returns(new QueryResult<Device>(new[] { device })); + deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false); + + var loggedOut = new TaskCompletionSource(); + var sessionManager = new Mock<ISessionManager>(); + sessionManager.Setup(s => s.Logout(It.IsAny<Device>())) + .Callback(() => loggedOut.TrySetResult()) + .Returns(Task.CompletedTask); + + var userManager = new Mock<IUserManager>(); + var host = new DeviceAccessHost( + userManager.Object, + deviceManager.Object, + sessionManager.Object, + NullLogger<DeviceAccessHost>.Instance); + await host.StartAsync(TestContext.Current.CancellationToken); + + userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user)); + + await loggedOut.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + sessionManager.Verify(s => s.Logout(device), Times.Once); + } + + private sealed class CapturingSynchronizationContext : SynchronizationContext + { + public List<Exception> Exceptions { get; } = new List<Exception>(); + + public override void Post(SendOrPostCallback d, object? state) => Run(d, state); + + public override void Send(SendOrPostCallback d, object? state) => Run(d, state); + + private void Run(SendOrPostCallback d, object? state) + { + try + { + d(state); + } + catch (Exception ex) + { + Exceptions.Add(ex); + } + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs new file mode 100644 index 0000000000..cb714a4014 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Cryptography; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public sealed class UserManagerProfileImageTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserManager _userManager; + + public UserManagerProfileImageTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock<ICryptoProvider>(); + var configManager = new Mock<IServerConfigurationManager>(); + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock<IApplicationHost>(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger<DefaultAuthenticationProvider>.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock<INetworkManager>().Object, + appHost.Object, + new Mock<IImageProcessor>().Object, + NullLogger<UserManager>.Instance, + configManager.Object, + new IPasswordResetProvider[] { defaultPasswordResetProvider }, + new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider }); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + [Fact] + public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage() + { + var user = await _userManager.CreateUserAsync("profileimageuser"); + + // Assign a profile image the same way the image endpoint does and persist it. + // UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity, + // so the in-memory instance below is never assigned the database generated key. + user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png")); + await _userManager.UpdateUserAsync(user); + + // Precondition reproducing the bug: the in-memory image still carries the default, + // never-persisted (temporary) key, while a real image row exists in the database. + Assert.Equal(0, user.ProfileImage.Id); + Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage); + + // This used to throw InvalidOperationException: + // "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'." + var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user)); + + Assert.Null(exception); + Assert.Null(user.ProfileImage); + Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage); + } + + [Fact] + public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing() + { + var user = await _userManager.CreateUserAsync("noprofileimageuser"); + + var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user)); + + Assert.Null(exception); + Assert.Null(user.ProfileImage); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish<T>(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync<T>(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs index 4cea53bd3d..2bf1d1d05b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs @@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users [InlineData(" thishasaspaceatthestart")] [InlineData(" thishasaspaceatbothends ")] [InlineData(" this has a space at both ends and inbetween ")] + [InlineData(".")] + [InlineData("..")] public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username) { Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username)); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs new file mode 100644 index 0000000000..e91ebdf1b6 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Model.Cryptography; +using MediaBrowser.Model.Users; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +public sealed class UserManagerUpdateUserTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserManager _userManager; + + public UserManagerUpdateUserTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock<ICryptoProvider>(); + var configManager = new Mock<IServerConfigurationManager>(); + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock<IApplicationHost>(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger<DefaultAuthenticationProvider>.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock<INetworkManager>().Object, + appHost.Object, + new Mock<IImageProcessor>().Object, + NullLogger<UserManager>.Instance, + configManager.Object, + [defaultPasswordResetProvider], + [defaultAuthProvider, invalidAuthProvider]); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + [Fact] + public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences() + { + var user = await _userManager.CreateUserAsync("orphanuser"); + var permissionCount = user.Permissions.Count; + var preferenceCount = user.Preferences.Count; + + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + await _userManager.UpdateUserAsync(user); + + await using var context = CreateDbContext(); + Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken)); + Assert.All( + await context.Permissions.ToListAsync(TestContext.Current.CancellationToken), + permission => Assert.Equal(user.Id, permission.UserId)); + Assert.All( + await context.Preferences.ToListAsync(TestContext.Current.CancellationToken), + preference => Assert.Equal(user.Id, preference.UserId)); + } + + [Fact] + public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched() + { + var user = await _userManager.CreateUserAsync("churnuser"); + var before = await ReadChildRowsAsync(); + + // A session activity stamp goes through the same path. It must not rewrite all 37 child + // rows, which is what tearing the collections down and rebuilding them used to do. + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + + Assert.Equal(before, await ReadChildRowsAsync()); + } + + [Fact] + public async Task UpdatePolicyAsync_RaisesOnUserUpdated() + { + var user = await _userManager.CreateUserAsync("policyeventuser"); + + User? updated = null; + _userManager.OnUserUpdated += (_, e) => updated = e.Argument; + + await _userManager.UpdatePolicyAsync( + user.Id, + new UserPolicy + { + EnableAllDevices = false, + AuthenticationProviderId = user.AuthenticationProviderId, + PasswordResetProviderId = user.PasswordResetProviderId + }); + + Assert.NotNull(updated); + Assert.Equal(user.Id, updated.Id); + } + + [Fact] + public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges() + { + var user = await _userManager.CreateUserAsync("policyuser"); + Assert.False(user.HasPermission(PermissionKind.IsAdministrator)); + + user.SetPermission(PermissionKind.IsAdministrator, true); + user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]); + user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels)); + + await _userManager.UpdateUserAsync(user); + + var reloaded = _userManager.GetUserById(user.Id)!; + Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator)); + Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags)); + Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels); + + await using var context = CreateDbContext(); + Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + /// <summary> + /// Reads the identity and concurrency token of every permission and preference row. + /// </summary> + private async Task<List<(string Table, int Id, int Kind, uint RowVersion)>> ReadChildRowsAsync() + { + await using var context = CreateDbContext(); + var permissions = await context.Permissions + .OrderBy(permission => permission.Id) + .Select(permission => new ValueTuple<string, int, int, uint>("Permission", permission.Id, (int)permission.Kind, permission.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + var preferences = await context.Preferences + .OrderBy(preference => preference.Id) + .Select(preference => new ValueTuple<string, int, int, uint>("Preference", preference.Id, (int)preference.Kind, preference.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + + return permissions.Concat(preferences).ToList(); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish<T>(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync<T>(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index 2de6408cc6..79b9d1e2c5 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -6,8 +6,11 @@ using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Models.LibraryStructureDto; using Jellyfin.Extensions.Json; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.DependencyInjection; using Xunit; using Xunit.v3.Priority; @@ -26,6 +29,45 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl } [Fact] + [Priority(-3)] + public async Task AddVirtualFolder_WithWarmDirectoryServiceCache_InvalidatesTheParentListing() + { + const string Name = "stale-cache-test"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var directoryService = _factory.Services.GetRequiredService<IDirectoryService>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + // Cache a listing of the libraries root taken before the new folder exists. Everything + // resolving through this DirectoryService keeps reading that listing until it is dropped, + // so the library stays invisible. Making the caches shared once turned this into a real + // test failure, see UpdateLibraryOptions_Valid_Success. + Assert.DoesNotContain( + directoryService.GetFileSystemEntries(rootFolderPath), + x => string.Equals(x.Name, Name, StringComparison.Ordinal)); + + var body = new AddVirtualFolderDto() + { + LibraryOptions = new LibraryOptions() + { + Enabled = false + } + }; + + using var response = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + Assert.Contains( + directoryService.GetFileSystemEntries(rootFolderPath), + x => string.Equals(x.Name, Name, StringComparison.Ordinal)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } + + [Fact] [Priority(-1)] public async Task Post_NewVirtualFolder_NotFound() { @@ -114,6 +156,58 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Theory] + [Priority(1)] + [InlineData("..")] + [InlineData("../..")] + [InlineData(".")] + [InlineData("test/../..")] + [InlineData("/var/lib/jellyfin/data")] + public async Task DeleteLibrary_PathTraversal_NotFound(string name) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + using var response = await client.DeleteAsync($"Library/VirtualFolders?name={Uri.EscapeDataString(name)}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [Priority(1)] + [InlineData("..")] + [InlineData("../..")] + [InlineData(".")] + [InlineData("test/../..")] + [InlineData("/var/lib/jellyfin/data")] + public async Task RenameLibrary_PathTraversalNewName_BadRequest(string newName) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + using var response = await client.PostAsync( + $"Library/VirtualFolders/Name?name=test&newName={Uri.EscapeDataString(newName)}", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [Priority(1)] + [InlineData("..")] + [InlineData("../..")] + [InlineData("/var/lib/jellyfin/data")] + public async Task RenameLibrary_PathTraversalName_NotFound(string name) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + using var response = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Uri.EscapeDataString(name)}&newName=renamed", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + [Fact] [Priority(1)] public async Task DeleteLibrary_Valid_Success() diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs new file mode 100644 index 0000000000..f84e28de76 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs @@ -0,0 +1,36 @@ +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class SyncPlayControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public SyncPlayControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetGroups_Unauthorized_ReturnsUnauthorized() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetGroups_InvalidToken_ReturnsUnauthorized() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader("invalid-token"); + + var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs new file mode 100644 index 0000000000..81cff49945 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs @@ -0,0 +1,26 @@ +using Jellyfin.Server.Implementations.Users; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +public sealed class HostedServiceRegistrationTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public HostedServiceRegistrationTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public void DeviceAccessHost_IsRegisteredAsHostedService() + { + _ = _factory.CreateClient(); + + var hostedServices = _factory.Services.GetServices<IHostedService>(); + + Assert.Contains(hostedServices, service => service is DeviceAccessHost); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs new file mode 100644 index 0000000000..a1149ac9be --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Globalization; +using System.IO; +using Jellyfin.Drawing; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +public sealed class ImageProcessorTests : IDisposable +{ + private const string CacheRoot = "image-cache"; + private const string OriginalPath = "/media/poster.jpg"; + private const string NoOverlayCacheKey = "/media/poster.jpg,quality=90,datemodified=638800000000000000,f=Jpg,width=200,height=300,maxwidth=400,maxheight=500,fillwidth=600,fillheight=700,blur=2,b=000000,fl=layer,v=4"; + private static readonly DateTime _dateModified = new(638800000000000000, DateTimeKind.Utc); + private readonly ImageProcessor _imageProcessor; + + public ImageProcessorTests() + { + var applicationPaths = new Mock<IServerApplicationPaths>(); + applicationPaths.SetupGet(paths => paths.ImageCachePath).Returns(CacheRoot); + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager + .SetupGet(manager => manager.Configuration) + .Returns(new ServerConfiguration { ParallelImageEncodingLimit = 1 }); + + _imageProcessor = new ImageProcessor( + NullLogger<ImageProcessor>.Instance, + applicationPaths.Object, + Mock.Of<IFileSystem>(), + Mock.Of<IImageEncoder>(), + configurationManager.Object); + } + + [Fact] + public void GetCacheFilePath_DifferentOverlayTypes_ReturnDifferentPaths() + { + var percentPlayedPath = GetCacheFilePath(percentPlayed: 1); + var unwatchedCountPath = GetCacheFilePath(unwatchedCount: 1); + + Assert.NotEqual(percentPlayedPath, unwatchedCountPath); + } + + [Fact] + public void GetCacheFilePath_DifferentPercentPlayedValues_ReturnDifferentPaths() + { + var firstPath = GetCacheFilePath(percentPlayed: 12.5); + var secondPath = GetCacheFilePath(percentPlayed: 75.5); + + Assert.NotEqual(firstPath, secondPath); + } + + [Fact] + public void GetCacheFilePath_DifferentUnwatchedCountValues_ReturnDifferentPaths() + { + var firstPath = GetCacheFilePath(unwatchedCount: 1); + var secondPath = GetCacheFilePath(unwatchedCount: 2); + + Assert.NotEqual(firstPath, secondPath); + } + + [Fact] + public void GetCacheFilePath_DifferentCultures_ReturnSamePath() + { + var originalCulture = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + var expectedPath = GetCacheFilePath(percentPlayed: 12.5); + + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var actualPath = GetCacheFilePath(percentPlayed: 12.5); + + Assert.Equal(expectedPath, actualPath); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void GetCacheFilePath_NoOverlay_UsesVersionFourWithExistingSerialization() + { + var expectedPath = _imageProcessor.GetCachePath( + Path.Combine(CacheRoot, "resized-images"), + NoOverlayCacheKey, + ".jpg"); + + Assert.Equal(expectedPath, GetCacheFilePath()); + } + + public void Dispose() + { + _imageProcessor.Dispose(); + } + + private string GetCacheFilePath(double percentPlayed = 0, int? unwatchedCount = null) + { + var options = new ImageProcessingOptions + { + Width = 200, + Height = 300, + MaxWidth = 400, + MaxHeight = 500, + FillWidth = 600, + FillHeight = 700, + Quality = 90, + PercentPlayed = percentPlayed, + UnplayedCount = unwatchedCount, + Blur = 2, + BackgroundColor = "000000", + ForegroundLayer = "layer" + }; + + return _imageProcessor.GetCacheFilePath( + OriginalPath, + _dateModified, + ImageFormat.Jpg, + options); + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs new file mode 100644 index 0000000000..3bd8581a5f --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Migrations; +using Jellyfin.Server.Migrations.Stages; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +public class CodeMigrationTests +{ + [Fact] + public async Task Perform_LeavesApplicationSingletonsAlive() + { + var services = new ServiceCollection() + .AddLogging() + .RegisterStartupLogger() + .AddSingleton<ApplicationSingleton>() + .AddTransient<MigrationTransient>(); + + await using var serviceProvider = services.BuildServiceProvider(); + var applicationSingleton = serviceProvider.GetRequiredService<ApplicationSingleton>(); + var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration"); + + var migration = new CodeMigration( + typeof(TestMigration), + new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)), + null); + await migration.Perform(serviceProvider, logger, CancellationToken.None); + + var performed = TestMigration.Performed; + Assert.NotNull(performed); + // The migration has to run against the applications own services, and they have to outlive it. + Assert.Same(applicationSingleton, performed.Singleton); + Assert.False(applicationSingleton.IsDisposed); + Assert.Same(applicationSingleton, serviceProvider.GetRequiredService<ApplicationSingleton>()); + // Services created for the migration itself are still owned by the migration. + Assert.True(performed.Transient.IsDisposed); + // The startup logger has to stay attached to the topic of the running migration. + Assert.Same(logger.Topic, performed.Logger.Topic); + } + + [Fact] + public async Task Perform_DoesNotLeakTheMigrationTopic() + { + var services = new ServiceCollection() + .AddLogging() + .RegisterStartupLogger() + .AddSingleton<ApplicationSingleton>() + .AddTransient<MigrationTransient>(); + + await using var serviceProvider = services.BuildServiceProvider(); + var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration"); + + var migration = new CodeMigration( + typeof(TestMigration), + new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)), + null); + await migration.Perform(serviceProvider, logger, CancellationToken.None); + + // The topic belongs to the migration that ran, so loggers resolved afterwards must not still write into it. + Assert.Null(serviceProvider.GetRequiredService<IStartupLogger<CodeMigrationTests>>().Topic); + Assert.Null(new StartupLogger(NullLogger.Instance).Topic); + } + + private sealed class ApplicationSingleton : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + } + + private sealed class MigrationTransient : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + } + + private sealed class TestMigration : IAsyncMigrationRoutine + { + public TestMigration(ApplicationSingleton singleton, MigrationTransient transient, IStartupLogger<TestMigration> logger) + { + Singleton = singleton; + Transient = transient; + Logger = logger; + } + + public static TestMigration? Performed { get; private set; } + + public ApplicationSingleton Singleton { get; } + + public MigrationTransient Transient { get; } + + public IStartupLogger<TestMigration> Logger { get; } + + public Task PerformAsync(CancellationToken cancellationToken) + { + Performed = this; + return Task.CompletedTask; + } + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs new file mode 100644 index 0000000000..25430447d4 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Migrations.Routines; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// <summary> +/// Covers the references a database carried up from 10.x can still hold against a view the migration +/// is about to drop. Only ParentId cascades; everything else here is a NO ACTION foreign key that +/// used to abort the migration, and with it the whole startup. +/// </summary> +public sealed class ConsolidateLocalizedUserViewsTests : IDisposable +{ + private const string MetadataPath = "/metadata"; + + private static readonly Guid _staleId = new("11111111-1111-1111-1111-111111111111"); + private static readonly Guid _canonicalId = new("22222222-2222-2222-2222-222222222222"); + + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + + public ConsolidateLocalizedUserViewsTests() + { + _connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task PerformAsync_ItemOwnedByStaleView_MovesItAndDropsTheView() + { + using (var context = CreateDbContext()) + { + context.BaseItems.Add(StaleView()); + context.BaseItems.Add(new BaseItemEntity { Id = Guid.NewGuid(), Type = "Trailer", OwnerId = _staleId }); + await context.SaveChangesAsync(Ct); + } + + await CreateMigration().PerformAsync(Ct); + + using (var context = CreateDbContext()) + { + Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct)); + Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Type == "Trailer", Ct)).OwnerId); + } + } + + [Fact] + public async Task PerformAsync_StaleViewInLinkedChildren_DropsTheLinksAndTheView() + { + var movieId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.Add(StaleView()); + context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = "Movie" }); + context.LinkedChildren.Add(new LinkedChildEntity { ParentId = _staleId, SortOrder = 0, ChildId = movieId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual }); + context.LinkedChildren.Add(new LinkedChildEntity { ParentId = movieId, SortOrder = 0, ChildId = _staleId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual }); + await context.SaveChangesAsync(Ct); + } + + await CreateMigration().PerformAsync(Ct); + + using (var context = CreateDbContext()) + { + Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct)); + Assert.Empty(context.LinkedChildren); + Assert.NotNull(await context.BaseItems.FindAsync([movieId], Ct)); + } + } + + [Fact] + public async Task PerformAsync_OrphanedAncestry_IsNotResurrectedUnderTheCanonicalView() + { + var childId = Guid.NewGuid(); + var orphanId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + context.BaseItems.Add(StaleView()); + context.BaseItems.Add(new BaseItemEntity { Id = childId, Type = "Movie", ParentId = _staleId }); + await context.SaveChangesAsync(Ct); + + context.AncestorIds.Add(new AncestorId { ItemId = childId, ParentItemId = _staleId, Item = null!, ParentItem = null! }); + await context.SaveChangesAsync(Ct); + + // Written while foreign keys went unenforced: the item behind it is long gone. + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", Ct); + context.AncestorIds.Add(new AncestorId { ItemId = orphanId, ParentItemId = _staleId, Item = null!, ParentItem = null! }); + await context.SaveChangesAsync(Ct); + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", Ct); + } + + await CreateMigration().PerformAsync(Ct); + + using (var context = CreateDbContext()) + { + Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct)); + Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Id.Equals(childId), Ct)).ParentId); + var ancestors = await context.AncestorIds.ToListAsync(Ct); + Assert.Equal(new[] { childId }, ancestors.Select(e => e.ItemId)); + Assert.Equal(_canonicalId, ancestors[0].ParentItemId); + } + } + + public void Dispose() + { + _connection.Dispose(); + GC.SuppressFinalize(this); + } + + private static BaseItemEntity StaleView() => new() + { + Id = _staleId, + Type = "MediaBrowser.Controller.Entities.UserView", + Path = Path.Combine(MetadataPath, "views", "livetv") + }; + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(new Mock<IApplicationPaths>().Object, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + + private ConsolidateLocalizedUserViews CreateMigration() + { + var view = new UserView + { + Id = _staleId, + Path = Path.Combine(MetadataPath, "views", "livetv"), + Name = "Live TV", + ViewType = CollectionType.livetv + }; + + var applicationPaths = new Mock<IServerApplicationPaths>(); + applicationPaths.Setup(e => e.InternalMetadataPath).Returns(MetadataPath); + + var configurationManager = new Mock<IServerConfigurationManager>(); + configurationManager.Setup(e => e.ApplicationPaths).Returns(applicationPaths.Object); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(e => e.GetValidFilename(It.IsAny<string>())).Returns((string name) => name); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(e => e.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(new List<BaseItem> { view }); + libraryManager.Setup(e => e.GetNewItemId(It.IsAny<string>(), It.IsAny<Type>())) + .Returns(_canonicalId); + libraryManager.Setup(e => e.CreateItem(It.IsAny<BaseItem>(), It.IsAny<BaseItem?>())) + .Callback((BaseItem item, BaseItem? parent) => + { + using var context = CreateDbContext(); + context.BaseItems.Add(new BaseItemEntity + { + Id = item.Id, + Type = item.GetType().FullName!, + Path = item.Path + }); + context.SaveChanges(); + }); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext); + + return new ConsolidateLocalizedUserViews( + new StartupLogger<ConsolidateLocalizedUserViews>(NullLogger<ConsolidateLocalizedUserViews>.Instance), + libraryManager.Object, + configurationManager.Object, + fileSystem.Object, + factory.Object); + } +} diff --git a/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs new file mode 100644 index 0000000000..34eff8a988 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Migrations.Routines; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests.Migrations; + +/// <summary> +/// Covers the repair of the PrimaryVersionId the item queries hide an alternate version by, +/// including the link shapes that would otherwise leave a whole version group hidden. +/// </summary> +public sealed class RepairAlternateVersionLinksTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly IApplicationPaths _applicationPaths; + + public RepairAlternateVersionLinksTests() + { + _applicationPaths = new Mock<IApplicationPaths>().Object; + + // The connection owns the in-memory database, so it stays open for the whole test. + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task PerformAsync_VersionLinkedToPrimary_PointsItAtThePrimary() + { + var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + Seed( + [(primaryId, null), (versionId, null)], + [(primaryId, versionId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + AssertIsVersionOf(context, versionId, primaryId); + AssertIsPrimary(context, primaryId); + } + + [Fact] + public async Task PerformAsync_VideosLinkedAsEachOthersVersion_KeepsOneOfThemVisible() + { + var firstId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + // Each one claims the other as its version, so pointing both at their link would hide the + // group in its entirety. + Seed( + [(firstId, null), (secondId, null)], + [(firstId, secondId), (secondId, firstId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + var first = Get(context, firstId); + var second = Get(context, secondId); + + var primary = first.PrimaryVersionId is null ? first : second; + var version = first.PrimaryVersionId is null ? second : first; + + Assert.Null(primary.PrimaryVersionId); + AssertIsPrimary(context, primary.Id); + AssertIsVersionOf(context, version.Id, primary.Id); + } + + [Fact] + public async Task PerformAsync_PrimaryStillPointingAtItsOwnVersion_ClearsTheStalePrimary() + { + var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + // The primary was promoted over the version it now heads, but kept the pointer to it: the + // repair below would point the version back and leave both hidden. + Seed( + [(primaryId, versionId), (versionId, null)], + [(primaryId, versionId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + AssertIsPrimary(context, primaryId); + AssertIsVersionOf(context, versionId, primaryId); + } + + [Fact] + public async Task PerformAsync_ChainedLinks_PointsEveryVersionAtTheHeadOfTheChain() + { + var headId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var middleId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var tailId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + // The middle one is a version of the head and a primary of the tail at the same time. + Seed( + [(headId, null), (middleId, null), (tailId, null)], + [(headId, middleId), (middleId, tailId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + AssertIsPrimary(context, headId); + AssertIsVersionOf(context, middleId, headId); + AssertIsVersionOf(context, tailId, headId); + } + + [Fact] + public async Task PerformAsync_VersionLinkedToItself_LeavesItVisible() + { + var itemId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + + Seed([(itemId, null)], [(itemId, itemId)]); + + await PerformAsync(); + + using var context = CreateDbContext(); + Assert.Null(Get(context, itemId).PrimaryVersionId); + } + + public void Dispose() + { + _connection.Dispose(); + } + + private static void AssertIsPrimary(JellyfinDbContext context, Guid id) + { + var item = Get(context, id); + Assert.Null(item.PrimaryVersionId); + Assert.Equal(id.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey); + } + + private static void AssertIsVersionOf(JellyfinDbContext context, Guid id, Guid primaryId) + { + var item = Get(context, id); + Assert.Equal(primaryId, item.PrimaryVersionId); + + // Presentation-key grouping has to collapse the version onto its primary as well. + Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey); + } + + private static BaseItemEntity Get(JellyfinDbContext context, Guid id) + => context.BaseItems.AsNoTracking().First(e => e.Id.Equals(id)); + + private JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + + private void Seed( + (Guid Id, Guid? PrimaryVersionId)[] items, + (Guid ParentId, Guid ChildId)[] links) + { + using var context = CreateDbContext(); + + foreach (var (id, primaryVersionId) in items) + { + context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = "MediaBrowser.Controller.Entities.Movies.Movie", + Name = "Movie", + PrimaryVersionId = primaryVersionId, + PresentationUniqueKey = (primaryVersionId ?? id).ToString("N", CultureInfo.InvariantCulture) + }); + } + + foreach (var (parentId, childId) in links) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = LinkedChildType.LinkedAlternateVersion + }); + } + + context.SaveChanges(); + } + + private Task PerformAsync() + { + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var migration = new RepairAlternateVersionLinks( + new StartupLogger<RepairAlternateVersionLinks>(NullLogger<RepairAlternateVersionLinks>.Instance), + factory.Object); + + return migration.PerformAsync(CancellationToken.None); + } +} diff --git a/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs new file mode 100644 index 0000000000..c2894e9647 --- /dev/null +++ b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs @@ -0,0 +1,54 @@ +using Jellyfin.Server.ServerSetupApp; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Tests.ServerSetupApp; + +public class StartupLoggerTests +{ + [Fact] + public void BeginAmbientTopic_AttachesNewLoggersToTheTopic() + { + var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration"); + + using (StartupLogger.BeginAmbientTopic(migration.Topic)) + { + Assert.Same(migration.Topic, new StartupLogger(NullLogger.Instance).Topic); + } + } + + [Fact] + public void BeginAmbientTopic_RestoresThePreviousTopic() + { + var root = new StartupLogger(NullLogger.Instance); + var outer = root.BeginGroup($"Outer"); + var inner = outer.BeginGroup($"Inner"); + + Assert.Null(new StartupLogger(NullLogger.Instance).Topic); + + using (StartupLogger.BeginAmbientTopic(outer.Topic)) + { + using (StartupLogger.BeginAmbientTopic(inner.Topic)) + { + Assert.Same(inner.Topic, new StartupLogger(NullLogger.Instance).Topic); + } + + // Leaving a nested topic has to fall back to the enclosing one, not to the setup UI root. + Assert.Same(outer.Topic, new StartupLogger(NullLogger.Instance).Topic); + } + + Assert.Null(new StartupLogger(NullLogger.Instance).Topic); + } + + [Fact] + public void BeginGroup_KeepsAnExplicitTopicOverTheAmbientOne() + { + var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration"); + var unrelated = new StartupLogger(NullLogger.Instance).BeginGroup($"Unrelated"); + + using (StartupLogger.BeginAmbientTopic(migration.Topic)) + { + Assert.Same(unrelated.Topic, unrelated.With(NullLogger.Instance).Topic); + } + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs index a04b37f215..3767b5c954 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs @@ -124,6 +124,27 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers } [Fact] + public void Fetch_Valid_MultiEpisode_Unordered_Success() + { + var result = new MetadataResult<Episode>() + { + Item = new Episode() + }; + + _parser.Fetch(result, "Test Data/Rising-Reversed.nfo", CancellationToken.None); + + var item = result.Item; + // The episodedetails blocks are stored in descending order, the merged episode must still be in ascending order + Assert.Equal("Rising (1) / Rising (2)", item.Name); + Assert.Equal(1, item.IndexNumber); + Assert.Equal(2, item.IndexNumberEnd); + Assert.Equal(1, item.ParentIndexNumber); + Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy. / Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.", item.Overview); + Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate); + Assert.Equal(2004, item.ProductionYear); + } + + [Fact] public void Fetch_Valid_MultiEpisode_With_Missing_Tags_Success() { var result = new MetadataResult<Episode>() diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo new file mode 100644 index 0000000000..6dbab13566 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo @@ -0,0 +1,43 @@ +<episodedetails> + <title>Rising (2)</title> + <season>1</season> + <episode>2</episode> + <aired>2004-07-16</aired> + <plot>Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.</plot> + <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25334.jpg</thumb> + <watched>false</watched> + <rating>7.9</rating> + <actor> + <name>Joe Flanigan</name> + <role>John Sheppard</role> + <order>0</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb> + </actor> + <actor> + <name>David Hewlett</name> + <role>Rodney McKay</role> + <order>1</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb> + </actor> +</episodedetails><episodedetails> + <title>Rising (1)</title> + <season>1</season> + <episode>1</episode> + <aired>2004-07-16</aired> + <plot>A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.</plot> + <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25333.jpg</thumb> + <watched>false</watched> + <rating>8.0</rating> + <actor> + <name>Joe Flanigan</name> + <role>John Sheppard</role> + <order>0</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb> + </actor> + <actor> + <name>David Hewlett</name> + <role>Rodney McKay</role> + <order>1</order> + <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb> + </actor> +</episodedetails> |
