diff options
488 files changed, 16431 insertions, 3661 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 5fc7834fcc..bc574d7054 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.8", + "version": "10.0.10", "commands": [ "dotnet-ef" ] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c67c292372..4ce0d7583a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,7 +33,7 @@ "libfontconfig1" ] }, - "ghcr.io/devcontainers/features/docker-in-docker:2": { + "ghcr.io/devcontainers/features/docker-in-docker:3": { "dockerDashComposeVersion": "v2" }, "ghcr.io/devcontainers/features/github-cli:1": {}, diff --git a/.devcontainer/install-ffmpeg.sh b/.devcontainer/install-ffmpeg.sh index 1e58e6ef44..1344634630 100644 --- a/.devcontainer/install-ffmpeg.sh +++ b/.devcontainer/install-ffmpeg.sh @@ -15,7 +15,7 @@ sudo apt-get install software-properties-common -y sudo add-apt-repository universe -y sudo mkdir -p /etc/apt/keyrings -curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg +curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/jellyfin.gpg export VERSION_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )" export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )" export DPKG_ARCHITECTURE="$( dpkg --print-architecture )" diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 45235be712..7a00dedbff 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,6 +87,9 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: + - 10.11.11 + - 10.11.10 + - 10.11.9 - 10.11.8 - 10.11.7 - 10.11.6 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index dc93d2c84e..d6833ea2be 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,15 @@ <!-- Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y). -For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation. +For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page. --> **Changes** <!-- Describe your changes here in 1-5 sentences. --> +**Code assistance** +<!-- If code assistance was used, describe how it contributed +e.g., code generated by LLM, explanation of code base, debugging guidance. --> + **Issues** <!-- Tag any issues that this PR solves here. ex. Fixes # --> diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index b14e5958ab..bc7ef7f5da 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -24,21 +24,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index dd48209a1f..f2c0a5c4cd 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -11,13 +11,13 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' @@ -40,14 +40,14 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml new file mode 100644 index 0000000000..4531c824ce --- /dev/null +++ b/.github/workflows/ci-format.yml @@ -0,0 +1,25 @@ +name: Format +on: + push: + branches: + - master + # Run formatter against the forked branch, but + # do not allow access to secrets + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories + pull_request: + +env: + SDK_VERSION: "10.0.x" + +jobs: + format-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.SDK_VERSION }} + + - name: Run DotNet Format + run: dotnet format --verify-no-changes --verbosity minimal diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 3c7ba54acf..f8704fb1eb 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -20,9 +20,9 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: ${{ env.SDK_VERSION }} diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 9d3d99cb71..b43ed3ee27 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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 339fcf569e..9adac6b995 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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.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 dcd1fb7cfe..1d3294798b 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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 dbfaf9d30b..09ab727c15 100644 --- a/.github/workflows/openapi-generate.yml +++ b/.github/workflows/openapi-generate.yml @@ -22,13 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} repository: ${{ inputs.repository }} - name: Configure .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' diff --git a/.github/workflows/openapi-pull-request.yml b/.github/workflows/openapi-pull-request.yml index 4acd0f4d4f..6b5d035420 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml index b003636a6e..ce671eb72e 100644 --- a/.github/workflows/pull-request-conflict.yml +++ b/.github/workflows/pull-request-conflict.yml @@ -4,19 +4,20 @@ on: push: branches: - master - pull_request: - issue_comment: + pull_request_target: + types: [synchronize] permissions: {} jobs: - label: - name: Labeling + main: runs-on: ubuntu-latest - if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }} + permissions: + contents: read + pull-requests: write + if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Apply label - uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 - if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}} + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 with: dirtyLabel: 'merge conflict' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index e114276c28..f92a9be9d5 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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.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 963b4a6023..d22e8bf6bd 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ env.TAG_BRANCH }} diff --git a/.gitignore b/.gitignore index d5a0367bff..381c15909d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ local.properties .classpath .settings/ .loadpath +*.lscache # External tool builders .externalToolBuilders/ @@ -277,3 +278,7 @@ apiclient/generated # Omnisharp crash logs mono_crash.*.json + +# Devcontainer temp files +.devcontainer/devcontainer-lock.json +dotnet/ diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 09a7198afe..4e323e332a 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -90,6 +90,7 @@ - [mark-monteiro](https://github.com/mark-monteiro) - [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti) - [Martin Reuter](https://github.com/reuterma24) + - [Matt Teahan](https://github.com/matt-teahan) - [Matt07211](https://github.com/Matt07211) - [Matthew Jones](https://github.com/matthew-jones-uk) - [Maxr1998](https://github.com/Maxr1998) @@ -114,6 +115,7 @@ - [oddstr13](https://github.com/oddstr13) - [olsh](https://github.com/olsh) - [orryverducci](https://github.com/orryverducci) + - [PCEWLKR](https://github.com/PCEWLKR) - [petermcneil](https://github.com/petermcneil) - [Phlogi](https://github.com/Phlogi) - [pjeanjean](https://github.com/pjeanjean) diff --git a/Directory.Packages.props b/Directory.Packages.props index e722bed973..a840d26ce1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,16 +9,16 @@ <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.5.4" /> + <PackageVersion Include="BitFaster.Caching" Version="2.6.0" /> <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" /> - <PackageVersion Include="coverlet.collector" Version="10.0.0" /> + <PackageVersion Include="coverlet.collector" Version="10.0.1" /> <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="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" /> + <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" /> <PackageVersion Include="Ignore" Version="0.2.1" /> @@ -26,60 +26,61 @@ <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.8" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" /> - <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" /> - <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" /> - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" /> + <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="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" /> <PackageVersion Include="prometheus-net" Version="8.2.1" /> - <PackageVersion Include="Polly" Version="8.6.6" /> + <PackageVersion Include="Polly" Version="8.7.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageVersion Include="Serilog.Expressions" Version="5.0.0" /> - <PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" /> + <PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" /> <PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> - <PackageVersion Include="SharpFuzz" Version="2.2.0" /> - <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 --> - <PackageVersion Include="SkiaSharp" Version="[3.116.1]" /> - <PackageVersion Include="SkiaSharp.HarfBuzz" Version="[3.116.1]" /> - <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" /> + <PackageVersion Include="SharpCompress" Version="0.49.1" /> + <PackageVersion Include="SharpFuzz" Version="2.3.0" /> + <PackageVersion Include="SkiaSharp" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" /> <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> - <PackageVersion Include="Svg.Skia" Version="3.4.1" /> - <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.1.7" /> - <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" /> - <PackageVersion Include="System.Text.Json" Version="10.0.8" /> + <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="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="7.13.0" /> + <PackageVersion Include="z440.atl.core" Version="7.15.3" /> <PackageVersion Include="TMDbLib" Version="3.0.0" /> <PackageVersion Include="UTF.Unknown" Version="2.6.0" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> diff --git a/Emby.Naming/Book/BookFileNameParser.cs b/Emby.Naming/Book/BookFileNameParser.cs index 28625f16de..080e25969b 100644 --- a/Emby.Naming/Book/BookFileNameParser.cs +++ b/Emby.Naming/Book/BookFileNameParser.cs @@ -1,3 +1,4 @@ +using System; using System.Text.RegularExpressions; namespace Emby.Naming.Book @@ -5,7 +6,7 @@ namespace Emby.Naming.Book /// <summary> /// Helper class to retrieve basic metadata from a book filename. /// </summary> - public static class BookFileNameParser + public static partial class BookFileNameParser { private const string NameMatchGroup = "name"; private const string IndexMatchGroup = "index"; @@ -15,14 +16,17 @@ namespace Emby.Naming.Book private static readonly Regex[] _nameMatches = [ // seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required - new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"), - new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"), - new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"), + new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"), + new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"), + new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"), new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"), // last resort matches the whole string as the name new Regex(@"(?<name>.*)") ]; + [GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")] + private static partial Regex ComicRegex(); + /// <summary> /// Parse a filename name to retrieve the book name, series name, index, and year. /// </summary> @@ -48,7 +52,22 @@ namespace Emby.Naming.Book if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success) { - result.Name = nameGroup.Value.Trim(); + var comicMatch = ComicRegex().Match(nameGroup.Value.Trim()); + + if (comicMatch.Success) + { + if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume)) + { + result.ParentIndex = volume; + } + + if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter)) + { + result.Index = chapter; + } + } + + result.Name = nameGroup.ValueSpan.Trim().ToString(); } if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index)) diff --git a/Emby.Naming/Book/BookFileNameParserResult.cs b/Emby.Naming/Book/BookFileNameParserResult.cs index f29716b9e3..f313b202c5 100644 --- a/Emby.Naming/Book/BookFileNameParserResult.cs +++ b/Emby.Naming/Book/BookFileNameParserResult.cs @@ -1,5 +1,3 @@ -using System; - namespace Emby.Naming.Book { /// <summary> @@ -14,6 +12,7 @@ namespace Emby.Naming.Book { Name = null; Index = null; + ParentIndex = null; Year = null; SeriesName = null; } @@ -29,6 +28,11 @@ namespace Emby.Naming.Book public int? Index { get; set; } /// <summary> + /// Gets or sets the parent index number. + /// </summary> + public int? ParentIndex { get; set; } + + /// <summary> /// Gets or sets the publication year. /// </summary> public int? Year { get; set; } diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 3461b3c0d6..8e7da5db42 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -70,7 +70,7 @@ namespace Emby.Naming.ExternalFiles if (lastSeparator == -1) { - break; + break; } string currentSlice = languageString[lastSeparator..]; diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index 8cd5a126e0..0c737964b4 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -125,7 +125,7 @@ namespace Emby.Naming.TV result.Success = true; } } - else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date)) + else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date)) { result.Year = date.Year; result.Month = date.Month; diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index ea4875e00a..9caebaf7ac 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -10,17 +10,25 @@ namespace Emby.Naming.TV /// </summary> public static partial class SeasonPathParser { + private const string SeasonKeywordPattern = + @"시즌|シーズン|сезон" + + @"|season|sæson|saison|staffel|series|stagione|säsong|seizoen|seasong" + + @"|sezon|sezona|sezóna|sezonul|série|séria|serie|seria|temporada|kausi"; + private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled); - [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:" + SeasonKeywordPattern + @")\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPre(); - [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^\s*(?:" + SeasonKeywordPattern + @")\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPost(); [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)] private static partial Regex SeasonPrefix(); + [GeneratedRegex(SeasonKeywordPattern, RegexOptions.IgnoreCase)] + private static partial Regex SeasonKeyword(); + /// <summary> /// Attempts to parse season number from path. /// </summary> @@ -91,14 +99,25 @@ namespace Emby.Naming.TV return (val, true); } + bool isMixedLibrary = !supportNumericSeasonFolders && !supportSpecialAliases; var preMatch = ProcessPre().Match(filename); if (preMatch.Success) { + if (isMixedLibrary && !SeasonKeyword().IsMatch(fileName)) + { + return (null, false); + } + return CheckMatch(preMatch); } else { var postMatch = ProcessPost().Match(filename); + if (postMatch.Success && isMixedLibrary && !SeasonKeyword().IsMatch(fileName)) + { + return (null, false); + } + return CheckMatch(postMatch); } } diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs index 5d6cb4bd37..e145ff8003 100644 --- a/Emby.Naming/TV/SeriesInfo.cs +++ b/Emby.Naming/TV/SeriesInfo.cs @@ -25,5 +25,11 @@ namespace Emby.Naming.TV /// </summary> /// <value>The name of the series.</value> public string? Name { get; set; } + + /// <summary> + /// Gets or sets the year of the series. + /// </summary> + /// <value>The year of the series.</value> + public int? Year { get; set; } } } diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index 0b7309bae0..733e2418c2 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -21,7 +21,7 @@ namespace Emby.Naming.TV /// Regex that matches titles with year in parentheses. Captures the title (which may be /// numeric) before the year, i.e. turns "1923 (2022)" into "1923". /// </summary> - [GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")] + [GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")] private static partial Regex TitleWithYearRegex(); /// <summary> @@ -43,7 +43,8 @@ namespace Emby.Naming.TV seriesName = titleWithYearMatch.Groups["title"].Value.Trim(); return new SeriesInfo(path) { - Name = seriesName + Name = seriesName, + Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null }; } } diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index ef5fa8bef9..aa19948e36 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -132,7 +132,7 @@ namespace Emby.Server.Implementations.AppBase } else { - _configurationFactories = [.._configurationFactories, factory]; + _configurationFactories = [.. _configurationFactories, factory]; } _configurationStores = _configurationFactories diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c81829688f..69e23bcb63 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -26,6 +26,7 @@ using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; +using Emby.Server.Implementations.Library.Search; using Emby.Server.Implementations.Library.SimilarItems; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Playlists; @@ -92,6 +93,9 @@ using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; +using MediaBrowser.Providers.Books; +using MediaBrowser.Providers.Books.ComicBookInfo; +using MediaBrowser.Providers.Books.ComicInfo; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.ListenBrainz; @@ -495,6 +499,14 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ListenBrainzLabsClient>(); serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>(); + // register the generic local metadata provider for comic files + serviceCollection.AddSingleton<ComicProvider>(); + + // register the actual implementations of the local metadata provider for comic files + serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>(); + serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>(); + serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>(); + serviceCollection.AddSingleton(NetManager); serviceCollection.AddSingleton<ITaskManager, TaskManager>(); @@ -539,6 +551,7 @@ namespace Emby.Server.Implementations serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); + serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>)); serviceCollection.AddSingleton<ILibraryManager, LibraryManager>(); serviceCollection.AddSingleton<NamingOptions>(); serviceCollection.AddSingleton<VideoListResolver>(); @@ -550,7 +563,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>(); - serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); + serviceCollection.AddSingleton<ISearchManager, SearchManager>(); + serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); @@ -709,6 +723,7 @@ namespace Emby.Server.Implementations Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); + Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>()); } /// <summary> diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index 8a4721ce62..69cbe533c6 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -240,15 +240,15 @@ public class ChapterManager : IChapterManager public void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters) { if (!Supports(item)) - { - _logger.LogWarning("Attempted to save chapters for unsupported item type {Type}: {Name} ({Id})", item.GetType().Name, item.Name, item.Id); - return; - } + { + _logger.LogWarning("Attempted to save chapters for unsupported item type {Type}: {Name} ({Id})", item.GetType().Name, item.Name, item.Id); + return; + } // Remove any chapters that are outside of the runtime of the item var validChapters = chapters.Where(c => c.StartPositionTicks < item.RunTimeTicks).ToList(); _chapterRepository.SaveChapters(item.Id, validChapters); -} + } /// <inheritdoc /> public ChapterInfo? GetChapter(Guid baseItemId, int index) diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 0ede5665f9..295efd456c 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -4,12 +4,15 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -29,6 +32,7 @@ namespace Emby.Server.Implementations.Collections private readonly ILibraryMonitor _iLibraryMonitor; private readonly ILogger<CollectionManager> _logger; private readonly IProviderManager _providerManager; + private readonly ILinkedChildrenService _linkedChildrenService; private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; @@ -42,6 +46,7 @@ namespace Emby.Server.Implementations.Collections /// <param name="iLibraryMonitor">The library monitor.</param> /// <param name="loggerFactory">The logger factory.</param> /// <param name="providerManager">The provider manager.</param> + /// <param name="linkedChildrenService">The linked children service.</param> public CollectionManager( ILibraryManager libraryManager, IApplicationPaths appPaths, @@ -49,13 +54,15 @@ namespace Emby.Server.Implementations.Collections IFileSystem fileSystem, ILibraryMonitor iLibraryMonitor, ILoggerFactory loggerFactory, - IProviderManager providerManager) + IProviderManager providerManager, + ILinkedChildrenService linkedChildrenService) { _libraryManager = libraryManager; _fileSystem = fileSystem; _iLibraryMonitor = iLibraryMonitor; _logger = loggerFactory.CreateLogger<CollectionManager>(); _providerManager = providerManager; + _linkedChildrenService = linkedChildrenService; _localizationManager = localizationManager; _appPaths = appPaths; } @@ -120,6 +127,22 @@ namespace Emby.Server.Implementations.Collections return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } + /// <inheritdoc /> + public IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId) + { + ArgumentNullException.ThrowIfNull(user); + + if (itemId.IsEmpty()) + { + return Enumerable.Empty<BoxSet>(); + } + + return _linkedChildrenService + .GetManualLinkedParentIds(itemId, BaseItemKind.BoxSet) + .Select(parentId => _libraryManager.GetItemById<BoxSet>(parentId, user)) + .OfType<BoxSet>(); + } + private IEnumerable<BoxSet> GetCollections(User user) { var folder = GetCollectionsFolder(false).GetAwaiter().GetResult(); diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index 0b3c3bbd4f..4929568935 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; @@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Devices { + /// <summary> + /// Provides the persistent unique identifier of this server installation. + /// </summary> public class DeviceId { private readonly IApplicationPaths _appPaths; @@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices private string? _id; + /// <summary> + /// Initializes a new instance of the <see cref="DeviceId"/> class. + /// </summary> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param> public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger) { _appPaths = appPaths; _logger = logger; } + /// <summary> + /// Gets the device id, loading it from disk or generating and persisting a new one if none exists. + /// </summary> public string Value => _id ??= GetDeviceId(); private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 321c7da1c4..8cbf42585d 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto { BaseItemKind.Person, [ BaseItemKind.Audio, + BaseItemKind.AudioBook, + BaseItemKind.Book, BaseItemKind.Episode, BaseItemKind.Movie, BaseItemKind.LiveTvProgram, @@ -167,9 +169,13 @@ namespace Emby.Server.Implementations.Dto // Batch-fetch user data for all items Dictionary<Guid, UserItemData>? userDataBatch = null; + IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null; if (user is not null && options.EnableUserData) { userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user); + + // For items with alternate versions, the most recently played version drives resume. + resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user); } // Pre-compute collection folders once to avoid N+1 queries in CanDelete @@ -248,7 +254,8 @@ namespace Emby.Server.Implementations.Dto allCollectionFolders, childCountBatch, playedCountBatch, - artistsBatch); + artistsBatch, + resumeDataBatch?.GetValueOrDefault(item.Id)); if (item is LiveTvChannel tvChannel) { @@ -309,7 +316,8 @@ namespace Emby.Server.Implementations.Dto List<Folder>? allCollectionFolders = null, Dictionary<Guid, int>? childCountBatch = null, Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, - IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null) + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, + VersionResumeData? resumeData = null) { var dto = new BaseItemDto { @@ -353,7 +361,8 @@ namespace Emby.Server.Implementations.Dto options, userData, childCountBatch, - playedCountBatch); + playedCountBatch, + resumeData); } if (item is IHasMediaSources @@ -369,7 +378,7 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options, artistsBatch); + AttachBasicFields(dto, item, owner, options, artistsBatch, user); if (options.ContainsField(ItemFields.CanDelete)) { @@ -538,7 +547,8 @@ namespace Emby.Server.Implementations.Dto DtoOptions options, UserItemData? userData = null, Dictionary<Guid, int>? childCountBatch = null, - Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null) + Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, + VersionResumeData? resumeData = null) { if (item.IsFolder) { @@ -600,6 +610,9 @@ namespace Emby.Server.Implementations.Dto // Use pre-fetched user data dto.UserData = GetUserItemDataDto(userData, item.Id); item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options); + + // For items with alternate versions, the most recently played version drives resume. + resumeData?.ApplyTo(dto.UserData); } else { @@ -943,7 +956,8 @@ namespace Emby.Server.Implementations.Dto /// <param name="owner">The owner.</param> /// <param name="options">The options.</param> /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> - private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null) + /// <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) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -1257,7 +1271,12 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaSourceCount)) { - var mediaSourceCount = video.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; @@ -1366,6 +1385,25 @@ namespace Emby.Server.Implementations.Dto } } + if (options.GetImageLimit(ImageType.Primary) > 0) + { + var episodeSeason = episode.Season; + var seasonPrimaryTag = episodeSeason is not null + ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) + : null; + + if (seasonPrimaryTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeason!.Id; + dto.ParentPrimaryImageTag = seasonPrimaryTag; + } + else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeries.Id; + dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; + } + } + if (options.ContainsField(ItemFields.SeriesStudio)) { episodeSeries ??= episode.Series; @@ -1504,6 +1542,21 @@ namespace Emby.Server.Implementations.Dto private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner) { + if (item is UserView { ViewType: CollectionType.playlists } playlistsView + && options.GetImageLimit(ImageType.Primary) > 0 + && !playlistsView.DisplayParentId.IsEmpty()) + { + var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId); + var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0); + + if (displayParentPrimaryImage is not null) + { + dto.ImageTags?.Remove(ImageType.Primary); + dto.ParentPrimaryImageItemId = displayParent!.Id; + dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage); + } + } + if (!item.SupportsInheritedParentImages) { return; diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index e9bf3b93a7..dc7f972c13 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer { receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); } - catch (WebSocketException ex) + catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException) { + // ObjectDisposedException/OperationCanceledException: the socket was torn + // down underneath us (e.g. by the keep-alive watchdog after the connection + // was declared lost). Fall through so Closed is still raised and the + // session can release this connection. _logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message); break; } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 199407044b..ede9b27592 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException) { - _logger.LogError(ex, "Failed to enumerate path {Path}", path); + _logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message); return Enumerable.Empty<string>(); } } diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 095934f896..7cae2a671b 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; +using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; @@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { @@ -28,51 +28,23 @@ namespace Emby.Server.Implementations.Images { var view = (CollectionFolder)item; var viewType = view.CollectionType; + var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); + var recursive = viewType != CollectionType.playlists; - BaseItemKind[] includeItemTypes; - - switch (viewType) + if (viewType == CollectionType.music) { - case CollectionType.movies: - includeItemTypes = new[] { BaseItemKind.Movie }; - break; - case CollectionType.tvshows: - includeItemTypes = new[] { BaseItemKind.Series }; - break; - case CollectionType.music: - includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead - break; - case CollectionType.musicvideos: - includeItemTypes = new[] { BaseItemKind.MusicVideo }; - break; - case CollectionType.books: - includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; - break; - case CollectionType.boxsets: - includeItemTypes = new[] { BaseItemKind.BoxSet }; - break; - case CollectionType.homevideos: - case CollectionType.photos: - includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo }; - break; - default: - includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series }; - break; + // Music albums usually don't have dedicated backdrops, so use artist instead + includeItemTypes = [BaseItemKind.MusicArtist]; } - var recursive = viewType != CollectionType.playlists; - return view.GetItemList(new InternalItemsQuery { CollapseBoxSetItems = false, Recursive = recursive, DtoOptions = new DtoOptions(false), - ImageTypes = new[] { ImageType.Primary }, + ImageTypes = [ImageType.Primary], Limit = 8, - OrderBy = new[] - { - (ItemSortBy.Random, SortOrder.Ascending) - }, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)], IncludeItemTypes = includeItemTypes }); } diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs index 4ad0f999bf..2c18e56df7 100644 --- a/Emby.Server.Implementations/Library/ExternalDataManager.cs +++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Chapters; @@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager /// <inheritdoc/> public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken) { - var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList(); - var itemId = item.Id; - if (validPaths.Count > 0) - { - foreach (var path in validPaths) - { - try - { - Directory.Delete(path, true); - } - catch (Exception ex) - { - _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); - } - } - } + DeleteExternalItemFiles(item); + var itemId = item.Id; await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false); } + + /// <inheritdoc/> + public void DeleteExternalItemFiles(BaseItem item) + { + foreach (var path in _pathManager.GetExtractedDataPaths(item)) + { + if (!Directory.Exists(path)) + { + continue; + } + + try + { + Directory.Delete(path, true); + } + catch (Exception ex) + { + _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex); + } + } + } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30ff1bd333..3691f4e19d 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; + private readonly Lazy<IExternalDataManager> _externalDataManagerFactory; /// <summary> /// The _root folder sync lock. @@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library /// <param name="pathManager">The path manager.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</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( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library IPeopleRepository peopleRepository, IPathManager pathManager, DotIgnoreIgnoreRule dotIgnoreIgnoreRule, - IMediaStreamRepository mediaStreamRepository) + IMediaStreamRepository mediaStreamRepository, + Lazy<IExternalDataManager> externalDataManagerFactory) { _appHost = appHost; _logger = loggerFactory.CreateLogger<LibraryManager>(); @@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library _configurationManager.ConfigurationUpdated += ConfigurationUpdated; _mediaStreamRepository = mediaStreamRepository; + _externalDataManagerFactory = externalDataManagerFactory; RecordConfigurationValues(_configurationManager.Configuration); } @@ -396,6 +400,12 @@ namespace Emby.Server.Implementations.Library } } + var externalDataManager = _externalDataManagerFactory.Value; + foreach (var (item, _, _) in pathMaps) + { + externalDataManager.DeleteExternalItemFiles(item); + } + _persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]); } @@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); + var externalDataManager = _externalDataManagerFactory.Value; + externalDataManager.DeleteExternalItemFiles(item); + foreach (var child in children) + { + externalDataManager.DeleteExternalItemFiles(child); + } + _persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]); _cache.TryRemove(item.Id, out _); foreach (var child in children) @@ -1987,7 +2004,8 @@ namespace Emby.Server.Implementations.Library query.TopParentIds.Length == 0 && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && - query.ItemIds.Length == 0) + query.ItemIds.Length == 0 && + query.OwnerIds.Length == 0) { var userViews = UserViewManager.GetUserViews(new UserViewQuery { @@ -2432,8 +2450,14 @@ namespace Emby.Server.Implementations.Library var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path is not null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray(); - // Skip image processing if current or live tv source - if (outdated.Length == 0 || item.SourceType != SourceType.Library) + + var parentItem = item.GetParent(); + var isLiveTvShow = item.SourceType != SourceType.Library && + parentItem is not null && + parentItem.SourceType != SourceType.Library; // not a channel + + // Skip image processing if current or live tv show + if (outdated.Length == 0 || isLiveTvShow) { RegisterItem(item); return; @@ -3394,6 +3418,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes) + { + return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes); + } + public void UpdatePeople(BaseItem item, List<PersonInfo> people) { UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index fdb4c7328b..97e00177b6 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -24,6 +24,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; @@ -127,6 +128,11 @@ namespace Emby.Server.Implementations.Library return true; } + if (stream.IsVobSubSubtitleStream) + { + return true; + } + return false; } @@ -171,6 +177,7 @@ namespace Emby.Server.Implementations.Library public async Task<IReadOnlyList<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) { var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); + ResolveSymlinkPaths(mediaSources, enablePathSubstitution); // If file is strm or main media stream is missing, force a metadata refresh with remote probing if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder @@ -187,6 +194,7 @@ namespace Emby.Server.Implementations.Library cancellationToken).ConfigureAwait(false); mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); + ResolveSymlinkPaths(mediaSources, enablePathSubstitution); } var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false); @@ -221,7 +229,11 @@ namespace Emby.Server.Implementations.Library list.Add(source); } - return SortMediaSources(list).ToArray(); + var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId) + ? topSourceId + : item.Id; + + return SortMediaSources(list, preferredId).ToArray(); } /// <inheritdoc />> @@ -319,6 +331,28 @@ namespace Emby.Server.Implementations.Library } } + /// <summary> + /// Resolves symlinked file paths on the supplied sources to the real on-disk target. + /// Skipped when <paramref name="enablePathSubstitution"/> is set because the path may + /// already have been rewritten to a UNC/URL meant for the client to consume directly. + /// </summary> + private static void ResolveSymlinkPaths(IReadOnlyList<MediaSourceInfo> sources, bool enablePathSubstitution) + { + if (enablePathSubstitution) + { + return; + } + + foreach (var source in sources) + { + if (source.Protocol == MediaProtocol.File + && FileSystemHelper.ResolveLinkTarget(source.Path, returnFinalTarget: true) is { Exists: true } target) + { + source.Path = target.FullName; + } + } + } + private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource) { var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimiter; @@ -356,6 +390,12 @@ namespace Emby.Server.Implementations.Library if (user is not null) { + sources = sources + .Where(source => !Guid.TryParse(source.Id, out var sourceId) + || sourceId.Equals(item.Id) + || _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null) + .ToArray(); + foreach (var source in sources) { SetDefaultAudioAndSubtitleStreamIndices(item, source, user); @@ -370,6 +410,59 @@ namespace Emby.Server.Implementations.Library source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); } } + + sources = SetAlternateVersionResumeStates(item, sources, user); + } + + return sources; + } + + /// <summary> + /// When the queried item is a primary, moves the most recently played version to the front so + /// that resuming without an explicit source selection plays the version that was last watched. + /// A directly queried alternate version keeps its own source first. Per-user playback position + /// is not surfaced on the source itself; it is carried by each version's own UserData. + /// </summary> + /// <param name="item">The queried item.</param> + /// <param name="sources">The item's media sources.</param> + /// <param name="user">The user.</param> + /// <returns>The media sources, reordered when a version drives resume.</returns> + private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user) + { + // For a video, multiple sources means alternate versions. + if (item is not Video video || sources.Count < 2) + { + return sources; + } + + var versions = video.GetAllVersions(); + if (versions.Count < 2) + { + return sources; + } + + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase); + foreach (var version in versions) + { + if (userDataByVersion.TryGetValue(version.Id, out var data)) + { + dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data; + } + } + + // Reorder only for a resumable (in-progress) version; + // a completed version has no position to resume, so it must not be pulled to the front here. + var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed( + sources, + source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null, + data => data.PlaybackPositionTicks > 0); + + if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource)) + { + var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource }; + reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource))); + return reordered; } return sources; @@ -440,10 +533,6 @@ namespace Emby.Server.Implementations.Library if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase)) { - originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage) - ? originalLanguage.Split(',').FirstOrDefault() - : null; - if (user.PlayDefaultAudioTrack) { source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex( @@ -498,17 +587,7 @@ namespace Emby.Server.Implementations.Library var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections; - var originalLanguage = item?.OriginalLanguage ?? item switch - { - Episode episode => episode.Series.OriginalLanguage, - Video video => video.GetOwner() switch - { - Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage, - BaseItem owner => owner.OriginalLanguage, - null => null - }, - _ => null - }; + var originalLanguage = item?.GetInheritedOriginalLanguage(); SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection); @@ -524,24 +603,32 @@ namespace Emby.Server.Implementations.Library } } - private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources) + private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default) { - return sources.OrderBy(i => - { - if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) + // The source belonging to the queried item sorts first so it stays the default that gets played. + var preferredId = preferredItemId.IsEmpty() + ? null + : preferredItemId.ToString("N", CultureInfo.InvariantCulture); + + return sources + .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => { - return 0; - } + if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) + { + return 0; + } - return 1; - }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) - .ThenByDescending(i => - { - var stream = i.VideoStream; + return 1; + }) + .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) + .ThenByDescending(i => + { + var stream = i.VideoStream; - return stream?.Width ?? 0; - }) - .Where(i => i.Type != MediaSourceType.Placeholder); + return stream?.Width ?? 0; + }) + .Where(i => i.Type != MediaSourceType.Placeholder); } public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index cfa3e7c31d..7d0f3900c5 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -29,38 +29,79 @@ 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 { '[' => ']', '(' => ')', '{' => '}', - _ => '\0' + _ => '\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 ef5edb9afa..fad948ad97 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -121,7 +121,11 @@ public class PathManager : IPathManager } paths.Add(GetTrickplayDirectory(item, false)); - paths.Add(GetTrickplayDirectory(item, true)); + if (!string.IsNullOrEmpty(item.Path)) + { + paths.Add(GetTrickplayDirectory(item, true)); + } + paths.Add(GetChapterImageFolderPath(item)); return paths; diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 1e885aad6e..7d51a0daa0 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -18,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; - protected override Book Resolve(ItemResolveArgs args) + protected override Book? Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); @@ -47,13 +45,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books Path = args.Path, Name = result.Name ?? string.Empty, IndexNumber = result.Index, + ParentIndexNumber = result.ParentIndex, ProductionYear = result.Year, SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)), IsInMixedFolder = true, }; } - private Book GetBook(ItemResolveArgs args) + private Book? GetBook(ItemResolveArgs args) { var bookFiles = args.FileSystemChildren.Where(f => { @@ -78,6 +77,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books Path = bookFiles[0].FullName, Name = result.Name ?? string.Empty, IndexNumber = result.Index, + ParentIndexNumber = result.ParentIndex, ProductionYear = result.Year, SeriesName = result.SeriesName ?? string.Empty, }; 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 14798dda65..74c1f69616 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,6 +1,7 @@ #nullable disable using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Jellyfin.Data.Enums; @@ -46,7 +47,16 @@ namespace Emby.Server.Implementations.Library.Resolvers } // It's a directory-based playlist if the directory contains a playlist file - var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); + IEnumerable<string> filePaths; + try + { + filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); + } + catch (IOException) + { + return null; + } + if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase))) { return new Playlist diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index c81a0adb89..8d40eab006 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -31,7 +31,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> - public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions) + public SeriesResolver(ILogger<SeriesResolver> logger, NamingOptions namingOptions) { _logger = logger; _namingOptions = namingOptions; @@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + if (args.Parent is not null && args.Parent.IsRoot) + { + return null; + } + var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path); var collectionType = args.GetCollectionType(); @@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return new Series { Path = args.Path, - Name = seriesInfo.Name + Name = seriesInfo.Name, + ProductionYear = seriesInfo.Year }; } } diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs new file mode 100644 index 0000000000..a5be3f07bd --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs @@ -0,0 +1,458 @@ +using System; +using System.Collections.Generic; +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.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Manages search providers and orchestrates search operations. +/// </summary> +public class SearchManager : ISearchManager +{ + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; + private readonly ILogger<SearchManager> _logger; + private IExternalSearchProvider[] _externalProviders = []; + private IInternalSearchProvider[] _internalProviders = []; + + /// <summary> + /// Initializes a new instance of the <see cref="SearchManager"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="queryHelpers">The shared item query helpers.</param> + /// <param name="logger">The logger.</param> + public SearchManager( + ILibraryManager libraryManager, + IUserManager userManager, + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers, + ILogger<SearchManager> logger) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; + _logger = logger; + } + + /// <inheritdoc/> + public void AddParts(IEnumerable<ISearchProvider> providers) + { + var allProviders = providers.OrderBy(p => p.Priority).ToArray(); + + _externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray(); + _internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray(); + + _logger.LogInformation( + "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}", + _externalProviders.Length, + string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")), + string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})"))); + } + + /// <inheritdoc/> + public IReadOnlyList<ISearchProvider> GetProviders() + { + return [.. _externalProviders, .. _internalProviders]; + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + + var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken); + var internalTask = _internalProviders.Length > 0 + ? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken) + : Task.FromResult<IReadOnlyList<SearchResult>>([]); + + 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()) + { + var user = _userManager.GetUserById(query.UserId.Value); + if (user is not null) + { + results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false); + } + } + + return results; + } + + private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync( + IReadOnlyList<SearchResult> candidates, + User user, + 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); + + Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)]; + + 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.ItemId)).ToList(); + if (filtered.Count < candidates.Count) + { + _logger.LogDebug( + "Dropped {Dropped} of {Total} search candidates due to user access filtering", + candidates.Count - filtered.Count, + candidates.Count); + } + + return filtered; + } + } + + /// <inheritdoc/> + public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var providerQuery = BuildProviderQuery(query); + var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false); + if (candidates.Count == 0) + { + return new QueryResult<SearchHintInfo>(); + } + + var candidateScores = BuildScoreLookup(candidates); + var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId); + + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + var internalQuery = new InternalItemsQuery(user) + { + ItemIds = candidateScores.Keys.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [], + MediaTypes = query.MediaTypes.ToArray(), + IncludeItemsByName = !query.ParentId.HasValue, + ParentId = query.ParentId ?? Guid.Empty, + Recursive = true, + IsKids = query.IsKids, + IsMovie = query.IsMovie, + IsNews = query.IsNews, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + DtoOptions = new DtoOptions + { + Fields = + [ + ItemFields.AirTime, + ItemFields.DateCreated, + ItemFields.ChannelInfo, + ItemFields.ParentId + ] + } + }; + + // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name + // rather than being stored as regular library items. They require special handling: + // 1. Convert ParentId to AncestorIds (to filter by library folder) + // 2. Set IncludeItemsByName = true (to include these virtual items in results) + // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally) + // 4. Use GetAllArtists() instead of GetItemList() to query the artist index + IReadOnlyList<BaseItem> items; + if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) + { + if (!internalQuery.ParentId.IsEmpty()) + { + internalQuery.AncestorIds = [internalQuery.ParentId]; + internalQuery.ParentId = Guid.Empty; + } + + internalQuery.IncludeItemsByName = true; + internalQuery.IncludeItemTypes = []; + items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList(); + } + else + { + items = _libraryManager.GetItemList(internalQuery); + } + + var orderedResults = items + .Select(item => new SearchHintInfo { Item = item }) + .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f)) + .ToList(); + + var totalCount = orderedResults.Count; + + if (query.StartIndex.HasValue) + { + orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList(); + } + + if (query.Limit.HasValue) + { + orderedResults = orderedResults.Take(query.Limit.Value).ToList(); + } + + return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults); + } + + private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync( + IEnumerable<ISearchProvider> providers, + SearchProviderQuery providerQuery, + string searchTerm, + CancellationToken cancellationToken) + { + var requestedLimit = providerQuery.Limit ?? 100; + var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray(); + if (applicable.Length == 0) + { + return []; + } + + var perProvider = await Task.WhenAll( + applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken))) + .ConfigureAwait(false); + + var bestScores = new Dictionary<Guid, float>(); + foreach (var providerResults in perProvider) + { + foreach (var result in providerResults) + { + UpdateBestScore(bestScores, result); + } + } + + return bestScores + .Select(kvp => new SearchResult(kvp.Key, kvp.Value)) + .OrderByDescending(r => r.Score) + .Take(requestedLimit) + .ToList(); + } + + private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync( + ISearchProvider provider, + SearchProviderQuery providerQuery, + string searchTerm, + int requestedLimit, + CancellationToken cancellationToken) + { + try + { + var results = provider is IExternalSearchProvider externalProvider + ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false) + : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug( + "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", + provider.Name, + results.Count, + searchTerm); + return results; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); + return []; + } + } + + private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync( + IExternalSearchProvider provider, + SearchProviderQuery providerQuery, + int requestedLimit, + CancellationToken cancellationToken) + { + var results = new List<SearchResult>(); + await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) + { + results.Add(result); + if (results.Count >= requestedLimit) + { + break; + } + } + + return results; + } + + private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result) + { + if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore) + { + bestScores[result.ItemId] = result.Score; + } + } + + private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results) + { + var lookup = new Dictionary<Guid, float>(results.Count); + foreach (var result in results) + { + lookup[result.ItemId] = result.Score; + } + + return lookup; + } + + private static SearchProviderQuery BuildProviderQuery(SearchQuery query) + { + var excludeItemTypes = BuildExcludeItemTypes(query); + var includeItemTypes = BuildIncludeItemTypes(query); + + // Remove any excluded types from includes + if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0) + { + includeItemTypes.RemoveAll(excludeItemTypes.Contains); + } + + return new SearchProviderQuery + { + SearchTerm = query.SearchTerm, + UserId = query.UserId.IsEmpty() ? null : query.UserId, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + MediaTypes = query.MediaTypes.ToArray(), + Limit = query.Limit, + ParentId = query.ParentId + }; + } + + private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query) + { + var excludeItemTypes = query.ExcludeItemTypes.ToList(); + + excludeItemTypes.Add(BaseItemKind.Year); + excludeItemTypes.Add(BaseItemKind.Folder); + excludeItemTypes.Add(BaseItemKind.CollectionFolder); + + if (!query.IncludeGenres) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Genre); + AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); + } + + if (!query.IncludePeople) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Person); + } + + if (!query.IncludeStudios) + { + AddIfMissing(excludeItemTypes, BaseItemKind.Studio); + } + + if (!query.IncludeArtists) + { + AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); + } + + return excludeItemTypes; + } + + private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query) + { + var includeItemTypes = query.IncludeItemTypes.ToList(); + if (query.IncludeMedia) + { + return includeItemTypes; + } + + if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre)) + { + AddIfMissing(includeItemTypes, BaseItemKind.Genre); + AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); + } + + if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person)) + { + AddIfMissing(includeItemTypes, BaseItemKind.Person); + } + + if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio)) + { + AddIfMissing(includeItemTypes, BaseItemKind.Studio); + } + + if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist)) + { + AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); + } + + return includeItemTypes; + } + + private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value) + => list.Count == 0 || list.Contains(value); + + private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value) + { + if (!list.Contains(value)) + { + list.Add(value); + } + } +} diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs new file mode 100644 index 0000000000..bc766f1c8c --- /dev/null +++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs @@ -0,0 +1,230 @@ +#pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + +using System; +using System.Collections.Generic; +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.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Emby.Server.Implementations.Library.Search; + +/// <summary> +/// Built-in SQL-based search provider that queries the library database directly. +/// </summary> +public class SqlSearchProvider : IInternalSearchProvider +{ + private const int DefaultSearchLimit = 100; + private const float ExactMatchScore = 100f; + private const float PrefixMatchScore = 80f; + private const float WordPrefixMatchScore = 75f; + private const float ContainsMatchScore = 50f; + + private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemTypeLookup _itemTypeLookup; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IItemQueryHelpers _queryHelpers; + + /// <summary> + /// Initializes a new instance of the <see cref="SqlSearchProvider"/> class. + /// </summary> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="itemTypeLookup">The item type lookup.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="queryHelpers">The shared item query helpers.</param> + public SqlSearchProvider( + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemTypeLookup itemTypeLookup, + ILibraryManager libraryManager, + IUserManager userManager, + IItemQueryHelpers queryHelpers) + { + _dbProvider = dbProvider; + _itemTypeLookup = itemTypeLookup; + _libraryManager = libraryManager; + _userManager = userManager; + _queryHelpers = queryHelpers; + } + + /// <inheritdoc/> + public string Name => "Database"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SearchProvider; + + /// <inheritdoc/> + public int Priority => 100; // Low priority - runs as fallback + + /// <inheritdoc/> + public bool CanSearch(SearchProviderQuery query) + { + // SQL search can always handle any query + return true; + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); + + var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics(); + if (string.IsNullOrEmpty(rawSearchTerm)) + { + return []; + } + + var cleanSearchTerm = rawSearchTerm.GetCleanValue(); + if (string.IsNullOrEmpty(cleanSearchTerm)) + { + return []; + } + + var cleanPrefix = cleanSearchTerm + " "; + // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName, + // so match it via a case-insensitive LIKE rather than a per-row case conversion + // that may not translate to SQL on every provider. + var likeOriginal = $"%{rawSearchTerm}%"; + var limit = query.Limit ?? DefaultSearchLimit; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // Lightweight projection: select only what's needed to score and identify items. + var dbQuery = dbContext.BaseItems + .AsNoTracking() + .Where(e => e.Id != _placeholderId) + .Where(e => !e.IsVirtualItem) + .Where(e => e.CleanName!.Contains(cleanSearchTerm) + || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))); + + dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes); + dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes); + dbQuery = ApplyParentFilter(dbQuery, query.ParentId); + dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId); + + // 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 + // directly without any per-row case conversion. Items that match only via + // OriginalTitle fall through to the Contains tier. + // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take + // satisfies EF Core's row-limiting-with-OrderBy requirement. + var scored = dbQuery.Select(e => new + { + e.Id, + Score = + (e.CleanName == cleanSearchTerm) ? ExactMatchScore + : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore + : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore + : ContainsMatchScore + }); + + return await scored + .OrderByDescending(x => x.Score) + .ThenBy(x => x.Id) + .Take(limit) + .Select(x => new SearchResult(x.Id, x.Score)) + .ToArrayAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + private IQueryable<BaseItemEntity> ApplyTypeFilter( + IQueryable<BaseItemEntity> query, + BaseItemKind[] includeItemTypes, + BaseItemKind[] excludeItemTypes) + { + if (includeItemTypes.Length > 0) + { + var includeTypeNames = MapKindsToTypeNames(includeItemTypes); + if (includeTypeNames.Count > 0) + { + query = query.Where(e => includeTypeNames.Contains(e.Type)); + } + } + else if (excludeItemTypes.Length > 0) + { + var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes); + if (excludeTypeNames.Count > 0) + { + query = query.Where(e => !excludeTypeNames.Contains(e.Type)); + } + } + + return query; + } + + private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter( + IQueryable<BaseItemEntity> query, + MediaType[] mediaTypes) + { + if (mediaTypes.Length == 0) + { + return query; + } + + var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray(); + return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType)); + } + + private static IQueryable<BaseItemEntity> ApplyParentFilter( + IQueryable<BaseItemEntity> query, + Guid? parentId) + { + if (!parentId.HasValue || parentId.Value.IsEmpty()) + { + return query; + } + + var pid = parentId.Value; + return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid)); + } + + private IQueryable<BaseItemEntity> ApplyUserAccessFilter( + JellyfinDbContext dbContext, + IQueryable<BaseItemEntity> query, + Guid? userId) + { + if (!userId.HasValue || userId.Value.IsEmpty()) + { + return query; + } + + var user = _userManager.GetUserById(userId.Value); + if (user is null) + { + return query; + } + + var accessFilter = new InternalItemsQuery(user); + _libraryManager.ConfigureUserAccess(accessFilter, user); + return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter); + } + + private List<string> MapKindsToTypeNames(BaseItemKind[] kinds) + { + var list = new List<string>(kinds.Length); + foreach (var kind in kinds) + { + if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null) + { + list.Add(name); + } + } + + return list; + } +} diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs deleted file mode 100644 index c682118597..0000000000 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ /dev/null @@ -1,200 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Enums; -using Jellyfin.Extensions; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace Emby.Server.Implementations.Library -{ - public class SearchEngine : ISearchEngine - { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - - public SearchEngine(ILibraryManager libraryManager, IUserManager userManager) - { - _libraryManager = libraryManager; - _userManager = userManager; - } - - public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query) - { - User? user = null; - if (!query.UserId.IsEmpty()) - { - user = _userManager.GetUserById(query.UserId); - } - - var results = GetSearchHints(query, user); - var totalRecordCount = results.Count; - - if (query.StartIndex.HasValue) - { - results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value); - } - - if (query.Limit.HasValue && query.Limit.Value > 0) - { - results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count)); - } - - return new QueryResult<SearchHintInfo>( - query.StartIndex, - totalRecordCount, - results); - } - - private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value) - { - if (!list.Contains(value)) - { - list.Add(value); - } - } - - /// <summary> - /// Gets the search hints. - /// </summary> - /// <param name="query">The query.</param> - /// <param name="user">The user.</param> - /// <returns>IEnumerable{SearchHintResult}.</returns> - /// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception> - private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user) - { - var searchTerm = query.SearchTerm; - - ArgumentException.ThrowIfNullOrEmpty(searchTerm); - - searchTerm = searchTerm.Trim().RemoveDiacritics(); - - var excludeItemTypes = query.ExcludeItemTypes.ToList(); - var includeItemTypes = query.IncludeItemTypes.ToList(); - - excludeItemTypes.Add(BaseItemKind.Year); - excludeItemTypes.Add(BaseItemKind.Folder); - - if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Genre); - AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Genre); - AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); - } - - if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Person); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Person); - } - - if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.Studio); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.Studio); - } - - if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) - { - if (!query.IncludeMedia) - { - AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); - } - } - else - { - AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); - } - - AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder); - AddIfMissing(excludeItemTypes, BaseItemKind.Folder); - var mediaTypes = query.MediaTypes.ToList(); - - if (includeItemTypes.Count > 0) - { - excludeItemTypes.Clear(); - mediaTypes.Clear(); - } - - var searchQuery = new InternalItemsQuery(user) - { - SearchTerm = searchTerm, - ExcludeItemTypes = excludeItemTypes.ToArray(), - IncludeItemTypes = includeItemTypes.ToArray(), - Limit = query.Limit, - IncludeItemsByName = !query.ParentId.HasValue, - ParentId = query.ParentId ?? Guid.Empty, - OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, - Recursive = true, - - IsKids = query.IsKids, - IsMovie = query.IsMovie, - IsNews = query.IsNews, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - MediaTypes = mediaTypes.ToArray(), - - DtoOptions = new DtoOptions - { - Fields = new ItemFields[] - { - ItemFields.AirTime, - ItemFields.DateCreated, - ItemFields.ChannelInfo, - ItemFields.ParentId - } - } - }; - - IReadOnlyList<BaseItem> mediaItems; - - if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) - { - if (!searchQuery.ParentId.IsEmpty()) - { - searchQuery.AncestorIds = [searchQuery.ParentId]; - searchQuery.ParentId = Guid.Empty; - } - - searchQuery.IncludeItemsByName = true; - searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>(); - mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList(); - } - else - { - mediaItems = _libraryManager.GetItemList(searchQuery); - } - - return mediaItems.Select(i => new SearchHintInfo - { - Item = i - }).ToList(); - } - } -} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index 93aa0574c0..57d1f7c770 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,37 +1,77 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Configuration; +using Microsoft.EntityFrameworkCore; +using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; namespace Emby.Server.Implementations.Library.SimilarItems; /// <summary> -/// Provides similar items for movies and trailers. +/// Provides similar items for movies and trailers using weighted scoring. /// </summary> -public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer> +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider { - private readonly ILibraryManager _libraryManager; + private const int GenreWeight = 10; + private const int TagWeight = 5; + private const int StudioWeight = 5; + private const int DirectorWeight = 50; + private const int ActorWeight = 15; + + // Caps the batch fan-out so downstream IN-list sizes (per-source scores, accessible-id + // load, navigation includes) stay bounded regardless of caller input. + private const int MaxBatchSourceItems = 64; + + private static readonly (ItemValueType Type, int Weight)[] _itemValueDimensions = + [ + (ItemValueType.Genre, GenreWeight), + (ItemValueType.Tags, TagWeight), + (ItemValueType.Studios, StudioWeight) + ]; + + private static readonly Dictionary<string, int> _personTypeWeights = new(StringComparer.Ordinal) + { + [nameof(PersonKind.Director)] = DirectorWeight, + [nameof(PersonKind.Actor)] = ActorWeight, + [nameof(PersonKind.GuestStar)] = ActorWeight, + }; + + private static readonly string[] _scoredPersonTypes = [.. _personTypeWeights.Keys]; + + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IItemQueryHelpers _queryHelpers; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class. /// </summary> - /// <param name="libraryManager">The library manager.</param> + /// <param name="dbProvider">The database context factory.</param> + /// <param name="queryHelpers">The shared query helpers.</param> /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <param name="libraryManager">The library manager.</param> public MovieSimilarItemsProvider( - ILibraryManager libraryManager, - IServerConfigurationManager serverConfigurationManager) + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers, + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager) { - _libraryManager = libraryManager; + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; } /// <inheritdoc/> @@ -41,15 +81,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; /// <inheritdoc/> - public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } /// <inheritdoc/> - public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) { - return Task.FromResult(GetSimilarMovieItems(item, query)); + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; } bool ILocalSimilarItemsProvider.Supports(Type itemType) @@ -63,29 +105,238 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) }; - private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + /// <inheritdoc/> + public async Task<Dictionary<Guid, IReadOnlyList<BaseItemDto>>> GetBatchSimilarItemsAsync( + IReadOnlyList<BaseItemDto> sourceItems, + SimilarItemsQuery query, + CancellationToken cancellationToken) { var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { includeItemTypes.Add(BaseItemKind.Trailer); includeItemTypes.Add(BaseItemKind.LiveTvProgram); } - var internalQuery = new InternalItemsQuery(query.User) + var limit = query.Limit ?? 50; + var dtoOptions = query.DtoOptions ?? new DtoOptions(); + + if (sourceItems.Count > MaxBatchSourceItems) { - Genres = item.Genres, - Tags = item.Tags, - Limit = query.Limit, - DtoOptions = query.DtoOptions ?? new DtoOptions(), - ExcludeItemIds = [.. query.ExcludeItemIds], - IncludeItemTypes = [.. includeItemTypes], - EnableGroupByMetadataKey = true, - EnableTotalRecordCount = false, - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] - }; + sourceItems = sourceItems.Take(MaxBatchSourceItems).ToList(); + } + + var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + // Phase 1: Score all candidates per source item + var sourceIds = sourceItems.Select(i => i.Id).ToList(); + var perSourceScores = await ComputeBatchScoresAsync(sourceIds, context, cancellationToken).ConfigureAwait(false); + + var allCandidateIds = new HashSet<Guid>(); + foreach (var (_, scores) in perSourceScores) + { + allCandidateIds.UnionWith( + scores.OrderByDescending(kvp => kvp.Value) + .Take(limit * 3) + .Select(kvp => kvp.Key)); + } + + var result = new Dictionary<Guid, IReadOnlyList<BaseItemDto>>(); + if (allCandidateIds.Count == 0) + { + return result; + } + + // Phase 2: One access filter for all candidates + var filter = new InternalItemsQuery(query.User) + { + IncludeItemTypes = [.. includeItemTypes], + ExcludeItemIds = [.. query.ExcludeItemIds], + DtoOptions = dtoOptions, + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + IsMovie = true, + IsPlayed = false + }; + + if (query.User is not null) + { + _libraryManager.ConfigureUserAccess(filter, query.User); + } + + _queryHelpers.PrepareFilterQuery(filter); + var baseQuery = _queryHelpers.PrepareItemQuery(context, filter); + baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter); + + var allCandidateIdsList = allCandidateIds.ToList(); + var accessibleItems = await baseQuery + .WhereOneOrMany(allCandidateIdsList, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + // Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey + var allOrderedIds = new HashSet<Guid>(); + var perSourceOrderedIds = new Dictionary<Guid, List<Guid>>(); + + foreach (var item in sourceItems) + { + if (!perSourceScores.TryGetValue(item.Id, out var scores)) + { + continue; + } + + var orderedIds = accessibleItems + .Where(x => scores.ContainsKey(x.Id)) + .OrderByDescending(x => scores.GetValueOrDefault(x.Id)) + .DistinctBy(x => x.PresentationUniqueKey) + .Take(limit) + .Select(x => x.Id) + .ToList(); + + if (orderedIds.Count > 0) + { + perSourceOrderedIds[item.Id] = orderedIds; + allOrderedIds.UnionWith(orderedIds); + } + } + + if (allOrderedIds.Count == 0) + { + return result; + } + + // Phase 4: One entity load for all results + var allOrderedIdsList = allOrderedIds.ToList(); + var entities = await _queryHelpers.ApplyNavigations( + context.BaseItems.AsNoTracking().WhereOneOrMany(allOrderedIdsList, e => e.Id), + filter) + .AsSplitQuery() + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var entitiesById = entities + .Select(e => _queryHelpers.DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToDictionary(i => i!.Id); - return _libraryManager.GetItemList(internalQuery); + // Phase 5: Split by source, preserving score order + foreach (var (sourceId, orderedIds) in perSourceOrderedIds) + { + var items = orderedIds + .Where(entitiesById.ContainsKey) + .Select(id => entitiesById[id]!) + .ToList(); + + if (items.Count > 0) + { + result[sourceId] = items; + } + } + + return result; + } + } + + private static async Task<Dictionary<Guid, Dictionary<Guid, int>>> ComputeBatchScoresAsync(List<Guid> sourceIds, JellyfinDbContext context, CancellationToken cancellationToken) + { + var result = new Dictionary<Guid, Dictionary<Guid, int>>(); + foreach (var id in sourceIds) + { + result[id] = []; + } + + foreach (var (valueType, weight) in _itemValueDimensions) + { + var sourceRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && m.ItemValue.Type == valueType) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var sourceMap = sourceRows.GroupBy(r => r.ItemId).ToDictionary(g => g.Key, g => g.Select(x => x.Key).ToHashSet()); + var allKeys = sourceMap.Values.SelectMany(v => v).Distinct().ToList(); + if (allKeys.Count == 0) + { + continue; + } + + var candidateRows = await context.ItemValuesMap.AsNoTracking() + .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue)) + .Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var keyToCandidates = candidateRows.GroupBy(r => r.Key).ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + ApplyDimensionScores(sourceIds, sourceMap, keyToCandidates, weight, result); + } + + var personSourceRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => sourceIds.Contains(m.ItemId) && _scoredPersonTypes.Contains(m.People.PersonType)) + .Select(m => new { m.ItemId, m.PeopleId, m.People.PersonType }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + if (personSourceRows.Count > 0) + { + var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking() + .Where(m => context.PeopleBaseItemMap + .Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType)) + .Select(s => s.PeopleId) + .Contains(m.PeopleId)) + .Select(m => new { m.ItemId, m.PeopleId }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var personToCandidates = personCandidateRows + .GroupBy(r => r.PeopleId) + .ToDictionary(g => g.Key, g => g.Select(x => x.ItemId).ToList()); + + foreach (var weightGroup in personSourceRows.GroupBy(r => _personTypeWeights[r.PersonType!])) + { + var sourceMap = weightGroup + .GroupBy(r => r.ItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.PeopleId).ToHashSet()); + ApplyDimensionScores(sourceIds, sourceMap, personToCandidates, weightGroup.Key, result); + } + } + + foreach (var sourceId in sourceIds) + { + var scoreMap = result[sourceId]; + scoreMap.Remove(sourceId); + if (scoreMap.Count == 0) + { + result.Remove(sourceId); + } + } + + return result; + } + + private static void ApplyDimensionScores<TKey>( + List<Guid> sourceIds, + Dictionary<Guid, HashSet<TKey>> sourceMap, + Dictionary<TKey, List<Guid>> keyToCandidates, + int weight, + Dictionary<Guid, Dictionary<Guid, int>> result) + where TKey : notnull + { + foreach (var sourceId in sourceIds) + { + if (!sourceMap.TryGetValue(sourceId, out var sourceKeys)) + { + continue; + } + + var scoreMap = result[sourceId]; + foreach (var key in sourceKeys) + { + if (!keyToCandidates.TryGetValue(key, out var candidates)) + { + continue; + } + + foreach (var candidateId in candidates) + { + scoreMap[candidateId] = scoreMap.GetValueOrDefault(candidateId) + weight; + } + } + } } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index b56779cf3f..d923cff07e 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -8,12 +8,16 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; @@ -30,6 +34,7 @@ public class SimilarItemsManager : ISimilarItemsManager private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; private ISimilarItemsProvider[] _similarItemsProviders = []; /// <summary> @@ -39,16 +44,19 @@ public class SimilarItemsManager : ISimilarItemsManager /// <param name="appPaths">The server application paths.</param> /// <param name="libraryManager">The library manager.</param> /// <param name="fileSystem">The file system.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> public SimilarItemsManager( ILogger<SimilarItemsManager> logger, IServerApplicationPaths appPaths, ILibraryManager libraryManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager) { _logger = logger; _appPaths = appPaths; _libraryManager = libraryManager; _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; } /// <inheritdoc/> @@ -117,6 +125,7 @@ public class SimilarItemsManager : ISimilarItemsManager var allResults = new List<(BaseItem Item, float Score)>(); var excludeIds = new HashSet<Guid> { item.Id }; + var excludeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { item.GetPresentationUniqueKey() }; foreach (var (providerOrder, provider) in orderedProviders.Index()) { if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested) @@ -141,7 +150,9 @@ public class SimilarItemsManager : ISimilarItemsManager foreach (var (position, resultItem) in items.Index()) { - if (excludeIds.Add(resultItem.Id)) + var isNewId = excludeIds.Add(resultItem.Id); + var isNewKey = excludeKeys.Add(resultItem.GetPresentationUniqueKey()); + if (isNewId && isNewKey) { var score = CalculateScore(null, providerOrder, position); allResults.Add((resultItem, score)); @@ -155,7 +166,7 @@ public class SimilarItemsManager : ISimilarItemsManager var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); if (cachedReferences is not null) { - var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); continue; } @@ -183,7 +194,7 @@ public class SimilarItemsManager : ISimilarItemsManager if (pendingBatch.Count >= BatchSize) { - var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); remaining -= resolvedItems.Count; pendingBatch.Clear(); @@ -198,7 +209,7 @@ public class SimilarItemsManager : ISimilarItemsManager // Resolve any remaining references in the last partial batch if (pendingBatch.Count > 0) { - var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); allResults.AddRange(resolvedItems); } @@ -225,20 +236,230 @@ public class SimilarItemsManager : ISimilarItemsManager .ToList(); } + /// <inheritdoc/> + public async Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dtoOptions); + + var recentlyPlayedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = [BaseItemKind.Movie], + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending)], + Limit = 7, + ParentId = parentId, + Recursive = true, + IsPlayed = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }); + + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Descending)], + Limit = 10, + IsFavoriteOrLiked = true, + ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), + EnableGroupByMetadataKey = true, + ParentId = parentId, + Recursive = true, + DtoOptions = dtoOptions + }); + + var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); + var recentDirectors = GetPeopleNames(mostRecentMovies, [PersonType.Director]); + var recentActors = GetPeopleNames(mostRecentMovies, [PersonType.Actor, PersonType.GuestStar]); + + // Cap baseline items to categoryLimit - the round-robin can't use more categories than that. + var recentlyPlayedBaseline = recentlyPlayedMovies.Count > categoryLimit + ? recentlyPlayedMovies.Take(categoryLimit).ToList() + : recentlyPlayedMovies; + var likedBaseline = likedMovies.Count > categoryLimit + ? likedMovies.Take(categoryLimit).ToList() + : likedMovies; + + var batchQuery = new SimilarItemsQuery + { + User = user, + Limit = itemLimit, + DtoOptions = dtoOptions + }; + + var similarToRecentlyPlayed = await GetSimilarItemsRecommendationsAsync( + recentlyPlayedBaseline, + RecommendationType.SimilarToRecentlyPlayed, + batchQuery, + cancellationToken).ConfigureAwait(false); + + var similarToLiked = await GetSimilarItemsRecommendationsAsync( + likedBaseline, + RecommendationType.SimilarToLikedItem, + batchQuery, + cancellationToken).ConfigureAwait(false); + + var hasDirectorFromRecentlyPlayed = GetPersonRecommendations(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed, itemTypes); + var hasActorFromRecentlyPlayed = GetPersonRecommendations(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed, itemTypes); + + // Use a single enumerator per list, listed twice so MoveNext advances it + // twice per round-robin pass (giving these categories double weight). + // IMPORTANT: Declare as IEnumerator<T> to box the List<T>.Enumerator struct once; + // using var would box separately per list insertion, creating independent copies. + IEnumerator<SimilarItemsRecommendation> similarToRecentlyPlayedEnum = similarToRecentlyPlayed.GetEnumerator(); + IEnumerator<SimilarItemsRecommendation> similarToLikedEnum = similarToLiked.GetEnumerator(); + + var categoryTypes = new List<IEnumerator<SimilarItemsRecommendation>> + { + similarToRecentlyPlayedEnum, + similarToRecentlyPlayedEnum, + similarToLikedEnum, + similarToLikedEnum, + hasDirectorFromRecentlyPlayed.GetEnumerator(), + hasActorFromRecentlyPlayed.GetEnumerator() + }; + + var categories = new List<SimilarItemsRecommendation>(); + while (categories.Count < categoryLimit) + { + var allEmpty = true; + foreach (var category in categoryTypes) + { + if (category.MoveNext()) + { + categories.Add(category.Current); + allEmpty = false; + + if (categories.Count >= categoryLimit) + { + break; + } + } + } + + if (allEmpty) + { + break; + } + } + + return [.. categories.OrderBy(i => i.RecommendationType)]; + } + + private async Task<IReadOnlyList<SimilarItemsRecommendation>> GetSimilarItemsRecommendationsAsync( + IReadOnlyList<BaseItem> baselineItems, + RecommendationType recommendationType, + SimilarItemsQuery query, + CancellationToken cancellationToken) + { + var batchProvider = _similarItemsProviders + .OfType<IBatchLocalSimilarItemsProvider>() + .FirstOrDefault(); + + if (batchProvider is null || baselineItems.Count == 0) + { + return []; + } + + var batchResults = await batchProvider.GetBatchSimilarItemsAsync(baselineItems, query, cancellationToken).ConfigureAwait(false); + + var recommendations = new List<SimilarItemsRecommendation>(baselineItems.Count); + foreach (var baseline in baselineItems) + { + if (batchResults.TryGetValue(baseline.Id, out var similar) && similar.Count > 0) + { + recommendations.Add(new SimilarItemsRecommendation + { + BaselineItemName = baseline.Name, + CategoryId = baseline.Id, + RecommendationType = recommendationType, + Items = similar + }); + } + } + + return recommendations; + } + + private IEnumerable<SimilarItemsRecommendation> GetPersonRecommendations( + User? user, + IReadOnlyList<string> names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type, + IReadOnlyList<BaseItemKind> itemTypes) + { + var personTypes = type == RecommendationType.HasDirectorFromRecentlyPlayed + ? [PersonType.Director] + : Array.Empty<string>(); + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + Limit = itemLimit + 2, + PersonTypes = personTypes, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + IsPlayed = false, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }) + .DistinctBy(i => i.GetProviderId(MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + yield return new SimilarItemsRecommendation + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = items + }; + } + } + } + + private IReadOnlyList<string> GetPeopleNames(IReadOnlyList<BaseItem> items, IReadOnlyList<string> personTypes) + { + var itemIds = items.Select(i => i.Id).ToArray(); + return _libraryManager.GetPeopleNamesByItems(itemIds, personTypes) + .Values + .SelectMany(names => names) + .Distinct() + .ToArray(); + } + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( IReadOnlyList<SimilarItemReference> references, int providerOrder, User? user, DtoOptions dtoOptions, BaseItemKind itemKind, - HashSet<Guid> excludeIds) + HashSet<Guid> excludeIds, + HashSet<string> excludeKeys) { if (references.Count == 0) { return []; } - var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>(); + var resolvedByKey = new Dictionary<string, (BaseItem Item, float Score)>(StringComparer.OrdinalIgnoreCase); var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance); foreach (var (position, match) in references.Index()) @@ -269,7 +490,13 @@ public class SimilarItemsManager : ISimilarItemsManager foreach (var item in items) { - if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id)) + if (excludeIds.Contains(item.Id)) + { + continue; + } + + var presentationKey = item.GetPresentationUniqueKey(); + if (excludeKeys.Contains(presentationKey)) { continue; } @@ -279,10 +506,9 @@ public class SimilarItemsManager : ISimilarItemsManager if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) { var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); - if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score) + if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score) { - excludeIds.Add(item.Id); - resolvedById[item.Id] = (item, score); + resolvedByKey[presentationKey] = (item, score); } break; @@ -290,7 +516,13 @@ public class SimilarItemsManager : ISimilarItemsManager } } - return [.. resolvedById.Values]; + foreach (var (key, entry) in resolvedByKey) + { + excludeIds.Add(entry.Item.Id); + excludeKeys.Add(key); + } + + return [.. resolvedByKey.Values]; } private static float CalculateScore(float? matchScore, int providerOrder, int position) diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 1281f1587f..f5c41e5670 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,36 +212,128 @@ 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 }; - } + 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; + } + + /// <inheritdoc /> + public VersionResumeData? GetResumeUserData(User user, BaseItem item) + { + return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id); + } + + /// <inheritdoc /> + public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user) + { + ArgumentNullException.ThrowIfNull(user); + + var result = new Dictionary<Guid, VersionResumeData>(); + + // Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data. + // Linked alternates are already known in memory; only the local-alternate existence check + // would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions), + // so collect those ids and resolve them all in a single query below. + List<Video>? candidates = null; + List<Guid>? localProbeIds = null; + foreach (var item in items) + { + if (item is not Video video || video.PrimaryVersionId.HasValue) + { + continue; + } + + (candidates ??= []).Add(video); + + if (video.LinkedAlternateVersions.Length == 0) + { + (localProbeIds ??= []).Add(video.Id); + } + } + + if (candidates is null) + { + return result; + } + + HashSet<Guid>? withLocalAlternates = null; + if (localProbeIds is not null) + { + using var dbContext = _repository.CreateDbContext(); + withLocalAlternates = dbContext.LinkedChildren + .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion) + .WhereOneOrMany(localProbeIds, lc => lc.ParentId) + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + } + + List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null; + List<BaseItem>? allVersions = null; + + foreach (var video in candidates) + { + // Only items that actually have alternate versions aggregate over them. + if (video.LinkedAlternateVersions.Length == 0 + && (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id))) + { + continue; + } + + var versions = video.GetAllVersions(); + if (versions.Count < 2) + { + continue; + } + + (versionGroups ??= []).Add((video.Id, versions)); + (allVersions ??= []).AddRange(versions); + } + + if (versionGroups is null) + { + return result; + } + + var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user); - result[item.Id] = userData; - var cacheKey = GetCacheKey(user.InternalId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); + foreach (var (primaryId, versions) in versionGroups) + { + // Consider both in-progress and completed versions so a finished alternate still marks the primary as played. + var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.PlaybackPositionTicks > 0 || data.Played); + + if (resumeVersion is not null) + { + result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]); } } @@ -259,12 +352,40 @@ 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() + 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()); @@ -281,6 +402,10 @@ namespace Emby.Server.Implementations.Library var dto = GetUserItemDataDto(userData, item.Id); item.FillUserDataDtoValues(dto, userData, itemDto, user, options); + + // For an item with alternate versions, surface the most recently played version's resume point. + GetResumeUserData(user, item)?.ApplyTo(dto); + return dto; } @@ -385,5 +510,41 @@ namespace Emby.Server.Implementations.Library return playedToCompletion; } + + /// <inheritdoc /> + public void ResetPlaybackStreamSelections(User user, BaseItem item) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(item); + + using var dbContext = _repository.CreateDbContext(); + var rows = dbContext.UserData + .Where(e => e.ItemId == item.Id && e.UserId == user.Id + && (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null)) + .ToList(); + + if (rows.Count == 0) + { + return; + } + + foreach (var row in rows) + { + row.AudioStreamIndex = null; + row.SubtitleStreamIndex = null; + } + + dbContext.SaveChanges(); + + var cacheKey = GetCacheKey(user.InternalId, item.Id); + if (_cache.TryGet(cacheKey, out var cached)) + { + cached.AudioStreamIndex = null; + cached.SubtitleStreamIndex = null; + _cache.AddOrUpdate(cacheKey, cached); + } + + item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); + } } } diff --git a/Emby.Server.Implementations/Localization/Core/az.json b/Emby.Server.Implementations/Localization/Core/az.json new file mode 100644 index 0000000000..6ab18c8534 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/az.json @@ -0,0 +1,19 @@ +{ + "Books": "Kitablar", + "HomeVideos": "Ev Videoları", + "Latest": "Ən son", + "MixedContent": "Qarışıq məzmun", + "Movies": "Filmlər", + "Music": "Musiqi", + "MusicVideos": "Musiqi Videoları", + "NameSeasonUnknown": "Mövsüm Naməlum", + "NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.", + "NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur", + "NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb", + "NotificationOptionAudioPlayback": "Audio oxutma başladı", + "NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı", + "NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi", + "NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu", + "NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi", + "NotificationOptionPluginError": "Plugin uğursuzluğu" +} diff --git a/Emby.Server.Implementations/Localization/Core/br.json b/Emby.Server.Implementations/Localization/Core/br.json new file mode 100644 index 0000000000..cedc87e5a6 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/br.json @@ -0,0 +1,63 @@ +{ + "Artists": "Arzourien", + "AuthenticationSucceededWithUserName": "{0} kennasket gant berzh", + "Books": "Levrioù", + "ChapterNameValue": "Pennad {0}", + "Collections": "Dastumadegoù", + "Default": "Dre ziouer", + "External": "Diavaez", + "FailedLoginAttemptWithUserName": "Kennaskañ c'hwitet gant {0}", + "Favorites": "Sinedoù", + "Folders": "Teuliadoù", + "Forced": "Rediet", + "Genres": "Doareoù", + "HeaderContinueWatching": "Kenderc'hel da sellet", + "HeaderFavoriteEpisodes": "Rannoù Karetañ", + "HeaderFavoriteShows": "Heuliadennoù Karetañ", + "HeaderLiveTV": "TV war-eeun", + "HeaderNextUp": "Da c'houde", + "HearingImpaired": "Tud fall o C'hleved", + "HomeVideos": "Videoioù Personel", + "Inherit": "Hêrezhiñ", + "LabelIpAddressValue": "Chomlec'h IP : {0}", + "LabelRunningTimeValue": "Padelezh : {0}", + "Latest": "Diwezhañ", + "AppDeviceValues": "Arload : {0}, Trobarzhell : {1}", + "LyricDownloadFailureFromForItem": "C'hwitet eo pellgargañ ar c'homzoù eus {0} evit {1}", + "MixedContent": "Danvez mesket", + "Movies": "Filmoù", + "Music": "Sonerezh", + "MusicVideos": "Videoioù Sonerezh", + "NameInstallFailed": "{0} c'hwitet war ar staliadur", + "NameSeasonNumber": "Koulzad {0}", + "NameSeasonUnknown": "Koulzad Dianav", + "NewVersionIsAvailable": "Ur stumm Servijer Jellyfin nevez a c'haller pellgargañ.", + "NotificationOptionApplicationUpdateAvailable": "Hizivadur an arload zo da gaout", + "NotificationOptionApplicationUpdateInstalled": "Hizivadur an arload staliet", + "NotificationOptionAudioPlayback": "Lenn aodio lañset", + "NotificationOptionAudioPlaybackStopped": "Lenn aodio ehanet", + "Original": "Orin", + "Photos": "Fotoioù", + "Shows": "Heuliadennoù", + "Undefined": "Dianav", + "TasksMaintenanceCategory": "Trezalc’h", + "TasksLibraryCategory": "Levraoueg", + "TasksApplicationCategory": "Arload", + "NotificationOptionInstallationFailed": "C'hwitet war staliañ", + "NotificationOptionPluginError": "Fazi Askouezh", + "NotificationOptionPluginInstalled": "Askouezh staliet", + "NotificationOptionPluginUninstalled": "Askouezh distaliet", + "ScheduledTaskFailedWithName": "c'hwitadenn war {0}", + "TvShows": "Heuliadennoù TV", + "VersionNumber": "Stumm {0}", + "TasksChannelsCategory": "Chadennoù enlinenn", + "TaskAudioNormalization": "Normalizadur an aodio", + "TaskRefreshPeople": "Freskaat ar gomedianed", + "TaskUpdatePlugins": "Hizivaat an askouezhioù", + "TaskRefreshChannels": "Freskaat ar chadennoù", + "TaskOptimizeDatabase": "Gwellekaat an diaz roadennoù", + "TaskKeyframeExtractor": "Eztenner skeudennoù-alc'hwez", + "NotificationOptionCameraImageUploaded": "Karget eo skeudenn ar benveg", + "NotificationOptionNewLibraryContent": "Danvez nevez ouzhpennet", + "NotificationOptionPluginUpdateInstalled": "Staliet eo hizivadur an askouezh" +} diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 1465a4d5e1..6c81726ee6 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Mou els fitxers existents d'imatges de previsualització segons la configuració de la mediateca.", "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" + "Original": "Original", + "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 9e3d4456a8..28f0e2df97 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.", "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" + "Original": "Originál", + "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index e30528b3cd..de56b6fd66 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -9,7 +9,7 @@ "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderContinueWatching": "Fortsæt afspilning", + "HeaderContinueWatching": "Fortsæt med at se", "HeaderFavoriteEpisodes": "Yndlingsafsnit", "HeaderFavoriteShows": "Yndlingsserier", "HeaderLiveTV": "Live-TV", @@ -65,7 +65,7 @@ "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.", "TaskDownloadMissingSubtitles": "Hent manglende undertekster", "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.", - "TaskUpdatePlugins": "Opdater plugins", + "TaskUpdatePlugins": "Opdatér plugins", "TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.", "TaskCleanLogs": "Ryd log-mappe", "TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.", @@ -79,10 +79,10 @@ "TaskRefreshChapterImages": "Udtræk kapitelbilleder", "TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.", "TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.", - "TaskRefreshChannels": "Opdater kanaler", + "TaskRefreshChannels": "Opdatér kanaler", "TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.", "TaskCleanTranscode": "Tøm omkodningsmappen", - "TaskRefreshPeople": "Opdater personer", + "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.", "TaskCleanActivityLog": "Ryd aktivitetslog", @@ -90,7 +90,7 @@ "Forced": "Tvunget", "Default": "Standard", "TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.", - "TaskOptimizeDatabase": "Optimer database", + "TaskOptimizeDatabase": "Optimér database", "TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.", "TaskKeyframeExtractor": "Udtræk nøglerammer", "External": "Ekstern", @@ -99,12 +99,14 @@ "TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.", "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.", "TaskAudioNormalization": "Lydnormalisering", - "TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen", + "TaskDownloadMissingLyricsDescription": "Download sangtekster", "TaskDownloadMissingLyrics": "Hent manglende sangtekster", "TaskExtractMediaSegments": "Scan for mediesegmenter", "TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder", "TaskMoveTrickplayImagesDescription": "Flyt eksisterende trickplay-billeder jævnfør biblioteksindstillinger.", "TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment.", "CleanupUserDataTask": "Brugerdata oprydningsopgave", - "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage." + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index d84afdc1b6..c0ad2c165a 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -50,7 +50,7 @@ "ScheduledTaskFailedWithName": "{0} αποτυχία", "Shows": "Σειρές", "StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.", - "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}", + "SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}", "TvShows": "Τηλεοπτικές Σειρές", "UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε", "UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί", @@ -106,5 +106,7 @@ "TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων", "TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.", "CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.", - "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη" + "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη", + "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}", + "Original": "Πρωτότυπο" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index be152b515f..298d60d277 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.", "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." + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/enm.json b/Emby.Server.Implementations/Localization/Core/enm.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/Emby.Server.Implementations/Localization/Core/enm.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 28366a41b7..bccfdd4c19 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.", "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay", "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" + "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios", + "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index d61ce396cb..563dce8fe6 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "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" + "Original": "Original", + "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 1f13451060..4404354a88 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegments": "Escaneo de segmentos de medios", "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay", "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." + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index d08f652e58..d080eb0236 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImages": "Siirrä Trickplay-kuvien sijainti", "TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan.", "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." + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 4fb9f4329c..8605a752db 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -1,7 +1,7 @@ { "Artists": "Listafólk", "Collections": "Søvn", - "Default": "Sjálvgildi", + "Default": "Forsett", "External": "Ytri", "Genres": "Greinar", "AppDeviceValues": "App: {0}, Eind: {1}", @@ -12,5 +12,41 @@ "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 strikað", + "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført", + "NotificationOptionUserLockedOut": "Brúkari útihýstur", + "Photos": "Ljósmyndir", + "PluginInstalledWithName": "{0} var innlagt", + "PluginUninstalledWithName": "{0} var strikað", + "PluginUpdatedWithName": "{0} varð 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" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 8badf551b3..1ee606cc64 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -107,5 +107,6 @@ "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh", "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" + "Original": "Bunaidh", + "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index d3740130ee..a68db0076a 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -106,5 +106,6 @@ "TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización", "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." + "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.", + "Original": "Orixinal" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index af34bf092e..48056b0bb8 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.", "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.", - "CleanupUserDataTask": "משימת ניקוי מידע משתמש" + "CleanupUserDataTask": "משימת ניקוי מידע משתמש", + "LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה", + "Original": "מקור" } diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json index dedbc56a74..b551608fd0 100644 --- a/Emby.Server.Implementations/Localization/Core/he_IL.json +++ b/Emby.Server.Implementations/Localization/Core/he_IL.json @@ -16,5 +16,97 @@ "HeaderLiveTV": "טלוויזיה בשידור חי", "HeaderNextUp": "הבא", "HearingImpaired": "ללקויי שמיעה", - "HomeVideos": "סרטונים ביתיים" + "HomeVideos": "סרטונים ביתיים", + "AppDeviceValues": "אפליקציה: {0}, מכשיר: {1}", + "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", + "Default": "בררת מחדל", + "FailedLoginAttemptWithUserName": "התחברות נכשלה מ {0}", + "Forced": "בכוח", + "Inherit": "ירש", + "LabelIpAddressValue": "כתובת IP: {0}", + "LabelRunningTimeValue": "זמן ריצה: {0}", + "Latest": "הכי חדש", + "LyricDownloadFailureFromForItem": "מילות שיר נכשלו לרדת מ{0} בשביל {1}", + "MixedContent": "תוכן מעורב", + "MusicVideos": "סרטוני מוזיקה", + "NameInstallFailed": "{0} התכנות כושלות", + "NameSeasonUnknown": "עונה לא ידוע", + "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", + "NotificationOptionApplicationUpdateAvailable": "גרסת אפליקציה חדשה זמינה להורדה", + "NotificationOptionApplicationUpdateInstalled": "עדכון אפליקציה הותקן", + "NotificationOptionAudioPlayback": "החלה השמעת אודיו", + "NotificationOptionAudioPlaybackStopped": "ניגון השמע הופסק", + "NotificationOptionCameraImageUploaded": "תמונת מצלמה עודכן", + "NotificationOptionInstallationFailed": "התקנה נכשלה", + "NotificationOptionNewLibraryContent": "תוכן חדש נוסף", + "NotificationOptionPluginError": "תוסף נכשל", + "NotificationOptionPluginInstalled": "תוסף הותקן", + "NotificationOptionPluginUninstalled": "תוסף נמחק", + "NotificationOptionPluginUpdateInstalled": "עידכון לתוסף הותקן", + "NotificationOptionServerRestartRequired": "נדרש התחול מחדש לשרת", + "NotificationOptionTaskFailed": "כשל במשימה מתוכננת", + "NotificationOptionUserLockedOut": "המשתמש ננעל", + "NotificationOptionVideoPlayback": "החלה הפעלת וידאו", + "NotificationOptionVideoPlaybackStopped": "הפעלת הסרטון הופסקה", + "Original": "מקורי", + "Photos": "תמונות", + "PluginInstalledWithName": "{0} הותקן", + "PluginUninstalledWithName": "{0} נמחק", + "PluginUpdatedWithName": "{0} עודכן", + "ScheduledTaskFailedWithName": "{0} נכשל", + "Shows": "סדרות", + "StartupEmbyServerIsLoading": "שרת Jellyfin נטען. אנא נסה שוב בקרוב.", + "SubtitleDownloadFailureFromForItem": "הורדת הכתוביות מ-{0} עבור {1} נכשלה", + "TvShows": "תוכניות טלויזיה", + "Undefined": "לא מוגדר", + "UserCreatedWithName": "המשתמש {0} נוצר", + "UserDeletedWithName": "המשתמש {0} נמחק", + "UserDownloadingItemWithValues": "{0} מוריד את {1}", + "UserLockedOutWithName": "המשתמש {0} ננעל בחוץ", + "UserOfflineFromDevice": "{0} התנתק מ-{1}", + "UserOnlineFromDevice": "{0} מחובר מ-{1}", + "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", + "UserStartedPlayingItemWithValues": "{0} מנגן ב-{1} ב-{2}", + "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} ב-{2}", + "VersionNumber": "גרסה {0}", + "TasksMaintenanceCategory": "תחזוקה", + "TasksLibraryCategory": "ספריה", + "TasksApplicationCategory": "אפליקציה", + "TasksChannelsCategory": "ערוצי אינטרנט", + "TaskCleanActivityLog": "נקה יומן פעילות", + "TaskCleanActivityLogDescription": "מוחק רשומות יומן פעילות ישנות יותר מהגיל שהוגדר.", + "TaskCleanCache": "נקה ספריית מטמון", + "TaskCleanCacheDescription": "מוחק קבצי מטמון שאינם נחוצים עוד על ידי המערכת.", + "TaskRefreshChapterImages": "חלץ תמונות פרק", + "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות עבור סרטונים שיש להם פרקים.", + "TaskAudioNormalization": "נורמליזציה של שמע", + "TaskAudioNormalizationDescription": "סורק קבצים לאיתור נתוני נרמול שמע.", + "TaskRefreshLibrary": "סרוק ספריית מדיה", + "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך לאיתור קבצים חדשים ומרענן מטא-דאטה.", + "TaskCleanLogs": "נקה ספריית יומן", + "TaskCleanLogsDescription": "מוחק קבצי יומן שגילם עולה על {0} ימים.", + "TaskRefreshPeople": "רענן אנשים", + "TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.", + "TaskRefreshTrickplayImages": "צור תמונות Trickplay", + "TaskRefreshTrickplayImagesDescription": "יוצר תצוגות מקדימות של trickplay עבור סרטונים בספריות מופעלות.", + "TaskUpdatePlugins": "עדכן פלאגינים", + "TaskUpdatePluginsDescription": "מוריד ומתקין עדכונים עבור תוספים שתצורתם נקבעה לעדכון אוטומטי.", + "TaskCleanTranscode": "נקה ספריית קידוד", + "TaskCleanTranscodeDescription": "תמחוק את קבצי הקידוד בני יותר מיום.", + "TaskRefreshChannels": "רענן ערוצים", + "TaskRefreshChannelsDescription": "מרענן את פרטי ערוץ האינטרנט.", + "TaskDownloadMissingLyrics": "הורד מילות שיר חסרות", + "TaskDownloadMissingLyricsDescription": "הורדות מילים לשירים", + "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות", + "TaskDownloadMissingSubtitlesDescription": "מחפש באינטרנט אחר כתוביות חסרות בהתבסס על תצורת מטא-דאטה.", + "TaskOptimizeDatabase": "בצע אופטימיזציה של מסד הנתונים", + "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים וקיצוץ שטח פנוי. הפעלת משימה זו לאחר סריקת הספרייה או ביצוע שינויים אחרים שמשמעותם שינויים בבסיס הנתונים עשויה לשפר את הביצועים.", + "TaskKeyframeExtractor": "מחלץ פריים מרכזי", + "TaskKeyframeExtractorDescription": "מחלץ פריימים מרכזיים מקבצי וידאו כדי ליצור רשימות השמעה HLS מדויקות יותר. משימה זו עשויה להימשך זמן רב.", + "TaskExtractMediaSegments": "סריקת מקטעי מדיה", + "TaskExtractMediaSegmentsDescription": "מחלץ או משיג קטעי מדיה מתוספים התומכים ב-MediaSegment.", + "TaskMoveTrickplayImages": "העברת מיקום תמונת Trickplay", + "TaskMoveTrickplayImagesDescription": "מעביר קבצי trickplay קיימים בהתאם להגדרות הספרייה.", + "CleanupUserDataTask": "משימת ניקוי נתוני משתמש", + "CleanupUserDataTaskDescription": "מנקה את כל נתוני המשתמש (מצב צפייה, סטטוס מועדף וכו') ממדיה שכבר לא הייתה קיימת במשך 90 יום לפחות." } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 8794339fb1..442c26b30b 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -107,5 +107,6 @@ "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" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index e1c1ec109d..1995d7a4cf 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -1,5 +1,5 @@ { - "AppDeviceValues": "Program: {0}, Eszköz: {1}", + "AppDeviceValues": "alkalmazás: {0}, eszköz: {1}", "Artists": "Előadók", "AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve", "Books": "Könyvek", @@ -107,5 +107,6 @@ "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.", "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" + "Original": "Eredeti", + "LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 65c03e70f2..3502ec39ad 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegments": "Scan Segmen media", "TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay", "TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang", - "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna" + "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna", + "LyricDownloadFailureFromForItem": "Lirik gagal di download dari {0} untuk {1}", + "Original": "Asli" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index c9ca00afdf..825ad3084e 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -103,5 +103,10 @@ "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar", "TaskExtractMediaSegments": "Skönnun efnishluta", "CleanupUserDataTask": "Hreinsun notendagagna", - "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." + "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.", + "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir", + "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins." } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 6053aec896..f13944e6be 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -107,5 +107,6 @@ "TaskExtractMediaSegments": "Scansiona Segmento Media", "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" + "Original": "Originale", + "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 39e5af717c..78b7ec744b 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。", "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク", - "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。" + "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。", + "LyricDownloadFailureFromForItem": "歌詞", + "Original": "オリジナル" } diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index c0b7a196f7..f7ca19d7f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -2,7 +2,7 @@ "Genres": "ჟანრები", "TasksApplicationCategory": "აპლიკაცია", "AppDeviceValues": "აპლიკაცია: {0}, მოწყობილობა: {1}", - "Artists": "არტისტი", + "Artists": "შემსრულებლები", "AuthenticationSucceededWithUserName": "{0} -ის ავთენტიკაცია წარმატებულია", "Books": "წიგნები", "Forced": "იძულებითი", @@ -20,9 +20,9 @@ "External": "გარე", "HeaderFavoriteEpisodes": "რჩეული ეპიზოდები", "HearingImpaired": "სმენადაქვეითებული", - "LabelRunningTimeValue": "ხანგრძლივობა: {0}", + "LabelRunningTimeValue": "გაშვების დრო: {0}", "MixedContent": "შერეული შემცველობა", - "MusicVideos": "მუსიკალური ვიდეოები", + "MusicVideos": "მუსიკის ვიდეოები", "NotificationOptionInstallationFailed": "დაყენების შეცდომა", "NotificationOptionApplicationUpdateInstalled": "აპლიკაციის განახლება დაყენებულია", "NotificationOptionAudioPlayback": "აუდიოს დაკვრა დაწყებულია", @@ -31,54 +31,54 @@ "PluginUninstalledWithName": "{0} წაიშალა", "VersionNumber": "ვერსია {0}", "TasksChannelsCategory": "ინტერნეტ-არხები", - "TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.", + "TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაციას.", "Collections": "კოლექციები", - "Default": "ნაგულისხმები", + "Default": "ნაგულისხმევი", "Favorites": "რჩეულები", "Folders": "საქაღალდეები", "HeaderFavoriteShows": "რჩეული სერიალები", - "HeaderLiveTV": "ლაივ ტელევიზია", + "HeaderLiveTV": "ცოცხალი ტელევიზია", "HeaderNextUp": "შემდეგი", "HomeVideos": "სახლის ვიდეოები", "NameSeasonNumber": "სეზონი {0}", "NameSeasonUnknown": "სეზონი უცნობია", - "NotificationOptionPluginError": "მოდულის შეცდომა", - "NotificationOptionPluginInstalled": "მოდული დაყენებულია", + "NotificationOptionPluginError": "დამატების შეცდომა", + "NotificationOptionPluginInstalled": "დამატება დაყენებულია", "NotificationOptionPluginUninstalled": "მოდული წაიშალა", - "ScheduledTaskFailedWithName": "{0} ვერ შესრულდა", + "ScheduledTaskFailedWithName": "{0} ჩავარდა", "TvShows": "სატელევიზიო სერიალები", "TaskRefreshPeople": "ხალხის განახლება", - "TaskUpdatePlugins": "მოდულების განახლება", + "TaskUpdatePlugins": "დამატებების განახლება", "TaskRefreshChannels": "არხების განახლება", "TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია", "TaskKeyframeExtractor": "საკვანძო კადრის გამომღები", "LabelIpAddressValue": "IP მისამართი: {0}", - "NameInstallFailed": "{0}-ის დაყენების შეცდომა", + "NameInstallFailed": "{0}-ის დაყენების ჩავარდა", "NotificationOptionApplicationUpdateAvailable": "ხელმისაწვდომია აპლიკაციის განახლება", "NotificationOptionAudioPlaybackStopped": "აუდიოს დაკვრა გაჩერებულია", "NotificationOptionNewLibraryContent": "ახალი შემცველობა დამატებულია", - "NotificationOptionPluginUpdateInstalled": "მოდულიs განახლება დაყენებულია", + "NotificationOptionPluginUpdateInstalled": "დამატების განახლება დაყენებულია", "NotificationOptionServerRestartRequired": "საჭიროა სერვერის გადატვირთვა", - "NotificationOptionTaskFailed": "გეგმიური დავალების შეცდომა", + "NotificationOptionTaskFailed": "დაგეგმილი ამოცანა ჩავარდა", "NotificationOptionUserLockedOut": "მომხმარებელი დაიბლოკა", "NotificationOptionVideoPlayback": "ვიდეოს დაკვრა დაწყებულია", "PluginInstalledWithName": "{0} დაყენებულია", "PluginUpdatedWithName": "{0} განახლდა", "TaskCleanActivityLog": "აქტივობების ჟურნალის გასუფთავება", - "TaskCleanCache": "ქეშის საქაღალდის გასუფთავება", - "TaskRefreshChapterImages": "თავის სურათების გაშლა", + "TaskCleanCache": "კეშის საქაღალდის გასუფთავება", + "TaskRefreshChapterImages": "თავის სურათების ამოღება", "TaskRefreshLibrary": "მედიის ბიბლიოთეკის სკანირება", "TaskCleanLogs": "ჟურნალის საქაღალდის გასუფთავება", "TaskCleanTranscode": "ტრანსკოდირების საქაღალდის გასუფთავება", - "TaskDownloadMissingSubtitles": "მიუწვდომელი სუბტიტრების გადმოწერა", - "UserDownloadingItemWithValues": "{0} -ი {1}-ს იწერს", + "TaskDownloadMissingSubtitles": "ნაკლული სუბტიტრების გადმოწერა", + "UserDownloadingItemWithValues": "{0} იწერს {1}-ს", "FailedLoginAttemptWithUserName": "შესვლის წარუმატებელი მცდელობა {0}-დან", "UserCreatedWithName": "მომხმარებელი {0} შეიქმნა", - "UserDeletedWithName": "მომხმარებელი {0} წაშლილია", - "UserOnlineFromDevice": "{0}-ი დაკავშირდა {1}-დან", - "UserOfflineFromDevice": "{0}-ი {1}-დან გაეთიშა", + "UserDeletedWithName": "მომხმარებელი {0} წაიშალა", + "UserOnlineFromDevice": "{0} ხაზზეა {1}-დან", + "UserOfflineFromDevice": "{0} გაითიშა {1}-დან", "UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია", - "UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე", + "UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე", "UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა", "UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე", "TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.", @@ -96,15 +96,17 @@ "TaskDownloadMissingSubtitlesDescription": "ეძებს ბიბლიოთეკაში მიუწვდომელ სუბტიტრებს ინტერნეტში მეტამონაცემებზე დაყრდნობით.", "TaskOptimizeDatabaseDescription": "კუმშავს მონაცემთა ბაზას ადგილის გათავისუფლებლად. ამ ამოცანის ბიბლიოთეკის სკანირების ან ნებისმიერი ცვლილების, რომელიც ბაზაში რამეს აკეთებს, გაშვებას შეუძლია ბაზის წარმადობა გაზარდოს.", "TaskRefreshTrickplayImagesDescription": "ქმნის trickplay წინასწარ ხედებს ვიდეოებისთვის დაშვებულ ბიბლიოთეკებში.", - "TaskRefreshTrickplayImages": "Trickplay სურათების გენერირება", - "TaskAudioNormalization": "აუდიოს ნორმალიზება", + "TaskRefreshTrickplayImages": "Trickplay სურათების გენერაცია", + "TaskAudioNormalization": "აუდიოს ნორმალიზაცია", "TaskAudioNormalizationDescription": "აანალიზებს ფაილებს აუდიოს ნორმალიზაციისთვის.", "TaskDownloadMissingLyrics": "მიუწვდომელი ლირიკების ჩამოტვირთვა", - "TaskDownloadMissingLyricsDescription": "ჩამოტვირთავს ამჟამად ბიბლიოთეკაში არარსებულ ლირიკებს სიმღერებისთვის", - "TaskExtractMediaSegments": "მედია სეგმენტების სკანირება", + "TaskDownloadMissingLyricsDescription": "გადმოწერს ლირიკას სიმღერებისთვის", + "TaskExtractMediaSegments": "მედიის სეგმენტების სკანირება", "TaskExtractMediaSegmentsDescription": "მედია სეგმენტების სკანირება მხარდაჭერილი მოდულებისთვის.", - "TaskMoveTrickplayImages": "Trickplay სურათების მიგრაცია", + "TaskMoveTrickplayImages": "Trickplay-ის გამოსახულებების მდებარეობის მიგრაცია", "TaskMoveTrickplayImagesDescription": "გადააქვს trickplay ფაილები ბიბლიოთეკის პარამეტრებზე დაყრდნობით.", - "CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავება", - "CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ." + "CleanupUserDataTask": "მომხმარებლების მონაცემების გასუფთავების ამოცანა", + "CleanupUserDataTaskDescription": "ასუფთავებს მომხმარებლების მონაცემებს (ყურების სტატუსი, ფავორიტები ანდ ა.შ) მედია ელემენტებისთვის რომლების 90 დღეზე მეტია აღარ არსებობენ.", + "LyricDownloadFailureFromForItem": "{1}-ისთვის {0}-დან ლირიკის გადმოწერა ჩავარდა", + "Original": "ორიგინალი" } diff --git a/Emby.Server.Implementations/Localization/Core/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json index f053619a7a..6009b50fe0 100644 --- a/Emby.Server.Implementations/Localization/Core/kn.json +++ b/Emby.Server.Implementations/Localization/Core/kn.json @@ -80,7 +80,7 @@ "NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ", "NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ", "NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", - "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", + "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ", "NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ", diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 5d64405d19..a210125d34 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyrics": "누락된 가사 다운로드", "TaskDownloadMissingLyricsDescription": "가사 다운로드", "CleanupUserDataTask": "사용자 데이터 정리 작업", - "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다." + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", + "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", + "Original": "원본" } diff --git a/Emby.Server.Implementations/Localization/Core/mi.json b/Emby.Server.Implementations/Localization/Core/mi.json index 000e82ebdf..74fae52dca 100644 --- a/Emby.Server.Implementations/Localization/Core/mi.json +++ b/Emby.Server.Implementations/Localization/Core/mi.json @@ -2,5 +2,14 @@ "AppDeviceValues": "Taupānga: {0}, Pūrere: {1}", "Artists": "Kaiwaiata", "AuthenticationSucceededWithUserName": "{0} has been successfully authenticated", - "Books": "Ngā pukapuka" + "Books": "Ngā pukapuka", + "Default": "Taunoa", + "Collections": "Kohinga", + "External": "Waho", + "Folders": "Kōpaki", + "Forced": "Kaha", + "Music": "Waiata", + "Movies": "Kiriata", + "Latest": "Hou", + "Inherit": "Riro" } diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index c43a5a7431..dbf2ed4648 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -98,5 +98,10 @@ "TaskAudioNormalization": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുക", "TaskAudioNormalizationDescription": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുന്ന ഡാറ്റയ്ക്കായി ഫയലുകൾ സ്കാൻ ചെയ്യുക.", "TaskRefreshTrickplayImages": "ട്രിക്ക് പ്ലേ ചിത്രങ്ങൾ സൃഷ്ടിക്കുക", - "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു." + "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു.", + "Original": "ഓറിജിനൽ", + "TaskDownloadMissingLyrics": "ഇല്ലാത്ത വരികൾ ഡൗൺലോഡ് ചെയ്യുക", + "TaskDownloadMissingLyricsDescription": "പാട്ടുകളുടെ വരികൾ ഡൗൺലോഡ് ചെയ്യുന്നു", + "TaskExtractMediaSegments": "മീഡിയ സെഗ്മെന്റ് സ്കാൻ", + "TaskExtractMediaSegmentsDescription": "മീഡിയസെഗ്മെന്റ് പ്രാപ്തമാക്കിയ പ്ലഗിനുകളിൽ നിന്ന് മീഡിയ സെഗ്മെന്റുകൾ എക്സ്ട്രാക്റ്റുചെയ്യുന്നു അല്ലെങ്കിൽ നേടുന്നു." } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 898f5892c9..9aea3adc22 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -8,7 +8,7 @@ "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "Favorites": "Favorieten", "Folders": "Mappen", - "HeaderContinueWatching": "Verder kijken", + "HeaderContinueWatching": "Verderkijken", "HeaderFavoriteEpisodes": "Favoriete afleveringen", "HeaderFavoriteShows": "Favoriete series", "HeaderLiveTV": "Live-tv", diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json index 0967ef424b..cad5640763 100644 --- a/Emby.Server.Implementations/Localization/Core/oc.json +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -1 +1,3 @@ -{} +{ + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}" +} diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 9b2ef063a6..1db500adf3 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de mídia de plug-ins habilitados para MediaSegment.", "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay", "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." + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index dd482d1e9b..ce7f6d120e 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -107,5 +107,6 @@ "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.", "CleanupUserDataTask": "Limpeza de dados de utilizador", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 15b1543d8e..ce338acf34 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -107,5 +107,6 @@ "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.", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index d7eccf5f25..40d5e3985d 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.", "CleanupUserDataTask": "Задача очистки пользовательских данных", "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.", - "Original": "Оригинальный" + "Original": "Оригинальный", + "LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json index 0967ef424b..8efc0d1f2e 100644 --- a/Emby.Server.Implementations/Localization/Core/si.json +++ b/Emby.Server.Implementations/Localization/Core/si.json @@ -1 +1,15 @@ -{} +{ + "AppDeviceValues": "ඇප්: {0}, උපාංග: {1}", + "Artists": "කලාකරුවන්", + "AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී", + "Books": "පොත්", + "ChapterNameValue": "{0} වෙනි පරිච්ඡේදය", + "Collections": "සංහිතා", + "Default": "පෙරනිමි", + "External": "බාහිර", + "FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය", + "Favorites": "ප්රියතමයන්", + "Folders": "ෆෝල්ඩර", + "Forced": "නියමිත", + "Genres": "ප්රභේද" +} diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index afea835bd4..7ae8857e5d 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní", "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne", "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í." + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 8c8ed3254a..a1b5b714af 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -106,5 +106,7 @@ "TaskAudioNormalization": "Normalizacija zvoka", "TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.", "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." + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index b1f76aafbb..e13f7b09e1 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -106,5 +106,7 @@ "TaskAudioNormalization": "Normalizimi i audios", "TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.", "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" + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 56806e25c1..92f309c80c 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -106,5 +106,7 @@ "CleanupUserDataTask": "Задатак чишћења корисничких података", "CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.", "TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање", - "TaskDownloadMissingLyricsDescription": "Преузми стихове песама" + "TaskDownloadMissingLyricsDescription": "Преузми стихове песама", + "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}", + "Original": "Изворно" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index af3fbbaded..7384967122 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.", "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" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/sw.json b/Emby.Server.Implementations/Localization/Core/sw.json index 0967ef424b..c117a0eae2 100644 --- a/Emby.Server.Implementations/Localization/Core/sw.json +++ b/Emby.Server.Implementations/Localization/Core/sw.json @@ -1 +1,5 @@ -{} +{ + "Artists": "Wasanii", + "Books": "Vitabu", + "Collections": "Mikusanyiko" +} diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json index 998d799a95..3215733c1a 100644 --- a/Emby.Server.Implementations/Localization/Core/uz.json +++ b/Emby.Server.Implementations/Localization/Core/uz.json @@ -82,5 +82,7 @@ "TaskRefreshChapterImages": "Sahnadan tasvirini chiqarish", "TaskRefreshChapterImagesDescription": "Sahnalarni o'z ichiga olgan videolar uchun eskizlarni yaratadi.", "TaskRefreshLibrary": "Media kutubxonangizni skanerlash", - "TaskCleanLogsDescription": "{0} kundan eski log fayllarni o'chiradi." + "TaskCleanLogsDescription": "{0} kundan eski log fayllarni o'chiradi.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "{0} dan {1} gacha qo'shiq matninin yuklab olishda xatolik ketdi" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 8c36139f29..18418ae0bc 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "从支持 MediaSegment 的插件中提取或获取媒体分段。", "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。", "CleanupUserDataTask": "用户数据清理任务", - "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。" + "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。", + "LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词", + "Original": "原始" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 8b9665cf9a..b2fcbc93a2 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -77,8 +77,8 @@ "TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。", "TaskCleanLogs": "清理日誌資料夾", "TaskRefreshLibrary": "掃描媒體櫃", - "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。", - "TaskRefreshChapterImages": "擷取章節圖片", + "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。", + "TaskRefreshChapterImages": "擷取章節圖像", "TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。", "TaskCleanCache": "清理快取(Cache)資料夾", "TasksChannelsCategory": "網路頻道", @@ -106,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。", "TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置", "CleanupUserDataTask": "清理使用者資料嘅任務", - "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。" + "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。", + "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞", + "Original": "原始" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 5dace3b0b7..4e0dff87b9 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImages": "遷移快轉縮圖位置", "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。", "CleanupUserDataTask": "用戶資料清理工作", - "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。" + "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。", + "Original": "原作", + "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 0b0b300d30..e7dd984ec4 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -318,13 +318,13 @@ namespace Emby.Server.Implementations.Localization // A lot of countries don't explicitly have a separate rating for adult content if (ratings.All(x => x.RatingScore?.Score != 1000)) { - ratings.Add(new ParentalRating("XXX", new(1000, null))); + ratings.Add(new ParentalRating("XXX", new(1000, null))); } // A lot of countries don't explicitly have a separate rating for banned content if (ratings.All(x => x.RatingScore?.Score != 1001)) { - ratings.Add(new ParentalRating("Banned", new(1001, null))); + ratings.Add(new ParentalRating("Banned", new(1001, null))); } return [.. ratings.OrderBy(r => r.RatingScore?.Score).ThenBy(r => r.RatingScore?.SubScore)]; @@ -356,6 +356,27 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(rating); + // 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); + + foreach (var ratingValue in ratingValues) + { + var score = GetSingleRatingScore(ratingValue, countryCode); + if (score is not null) + { + return score; + } + } + + return null; + } + + /// <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)) { @@ -364,7 +385,7 @@ namespace Emby.Server.Implementations.Localization // Convert ints directly // This may override some of the locale specific age ratings (but those always map to the same age) - if (int.TryParse(rating, out var ratingAge)) + if (TryParseRatingAsScore(rating, out var ratingAge)) { return new(ratingAge, null); } @@ -466,6 +487,13 @@ namespace Emby.Server.Implementations.Localization return true; } + // If it's not a recognized rating string, fall back to using the number as the score + if (TryParseRatingAsScore(ratingPart, out var numericScore)) + { + result = new ParentalRatingScore(numericScore, null); + return true; + } + _logger.LogWarning( "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", rating, @@ -480,6 +508,18 @@ namespace Emby.Server.Implementations.Localization return true; } + /// <summary> + /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+"). + /// </summary> + /// <param name="ratingValue">Rating value to parse.</param> + /// <param name="score">Parsed score.</param> + /// <returns>Returns true if parsing was successful.</returns> + private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score) + { + var trimmed = ratingValue.TrimEnd('+'); + return int.TryParse(trimmed, out score); + } + /// <inheritdoc /> public string GetLocalizedString(string phrase) { @@ -566,11 +606,15 @@ namespace Emby.Server.Implementations.Localization private static string GetResourceFilename(string culture) { - var parts = culture.Split('-'); + // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator. + // Normalize the casing (lower-case language, upper-case region) while preserving the separator + // so the result matches the embedded resource file name, which is case-sensitive. + var separatorIndex = culture.IndexOfAny(['-', '_']); - if (parts.Length == 2) + if (separatorIndex > 0) { - culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant(); + var separator = culture[separatorIndex]; + culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant(); } else { diff --git a/Emby.Server.Implementations/Localization/Ratings/gr.json b/Emby.Server.Implementations/Localization/Ratings/gr.json index 794bf0b313..73abab72d3 100644 --- a/Emby.Server.Implementations/Localization/Ratings/gr.json +++ b/Emby.Server.Implementations/Localization/Ratings/gr.json @@ -10,21 +10,42 @@ } }, { - "ratingStrings": ["K12"], + "ratingStrings": ["K12", "K-12", "12"], + "ratingScore": { + "score": 12, + "subScore": null + } + }, + { + "ratingStrings": ["K13", "K-13", "13"], "ratingScore": { "score": 13, "subScore": null } }, { - "ratingStrings": ["K15"], + "ratingStrings": ["K15", "K-15", "15"], "ratingScore": { "score": 15, "subScore": null } }, { - "ratingStrings": ["K18"], + "ratingStrings": ["K16", "K-16", "16"], + "ratingScore": { + "score": 16, + "subScore": null + } + }, + { + "ratingStrings": ["K17", "K-17", "17"], + "ratingScore": { + "score": 17, + "subScore": null + } + }, + { + "ratingStrings": ["K18", "K-18", "18", "18+"], "ratingScore": { "score": 18, "subScore": null diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json index 70084995d1..818565e16b 100644 --- a/Emby.Server.Implementations/Localization/Ratings/se.json +++ b/Emby.Server.Implementations/Localization/Ratings/se.json @@ -10,7 +10,7 @@ } }, { - "ratingStrings": ["7"], + "ratingStrings": ["7", "7+", "7 År", "Från 7 år"], "ratingScore": { "score": 7, "subScore": null @@ -31,7 +31,7 @@ } }, { - "ratingStrings": ["11"], + "ratingStrings": ["11", "11+", "11 År", "Från 11 år"], "ratingScore": { "score": 11, "subScore": null @@ -45,11 +45,18 @@ } }, { - "ratingStrings": ["15"], + "ratingStrings": ["15", "15+", "15 År", "Från 15 år"], "ratingScore": { "score": 15, "subScore": null } + }, + { + "ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"], + "ratingScore": { + "score": 18, + "subScore": null + } } ] } diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 91ccb16ef9..f699c99d85 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -564,7 +564,8 @@ namespace Emby.Server.Implementations.Plugins Id = instance.Id, Status = PluginStatus.Active, Name = instance.Name, - Version = instance.Version.ToString() + Version = instance.Version.ToString(), + ImageResourceName = (instance as IHasEmbeddedImage)?.ImageResourceName }) { Instance = instance diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index b2dc89be28..e4939205c9 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask { FileName = _mediaEncoder.EncoderPath, Arguments = args, - RedirectStandardOutput = false, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true }, }) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index f81309560e..f1e1579a1d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask EnableImages = false }, SourceTypes = [SourceType.Library], - IsVirtualItem = false + IsVirtualItem = false, + IncludeOwnedItems = true }) .OfType<Video>() .ToList(); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs index 51920c5b14..9cc6eb265a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask DtoOptions = new DtoOptions(true), SourceTypes = [SourceType.Library], Recursive = true, + IncludeOwnedItems = true, Limit = pagesize }; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 92d7a3907a..8d133dc074 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask private readonly ILogger<OptimizeDatabaseTask> _logger; private readonly ILocalizationManager _localization; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. @@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public OptimizeDatabaseTask( ILogger<OptimizeDatabaseTask> logger, ILocalizationManager localization, - IJellyfinDatabaseProvider jellyfinDatabaseProvider) + IJellyfinDatabaseProvider jellyfinDatabaseProvider, + ILibraryManager libraryManager) { _logger = logger; _localization = localization; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; + _libraryManager = libraryManager; } /// <inheritdoc /> @@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { + // Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in + // progress causes both operations to contend for the database and can stall the scan, so defer optimization + // until no scan is running. The task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + _logger.LogInformation("Skipping database optimization because a library scan is currently running."); + return; + } + _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); try diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 6e4e5c7808..dff9a473af 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -4,11 +4,18 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; @@ -20,6 +27,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly IFileSystem _fileSystem; + private readonly ILogger<PeopleValidationTask> _logger; + private readonly IItemTypeLookup _itemTypeLookup; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. @@ -27,11 +37,23 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param> - public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory) + /// <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="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param> + public PeopleValidationTask( + ILibraryManager libraryManager, + ILocalizationManager localization, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + IFileSystem fileSystem, + ILogger<PeopleValidationTask> logger, + IItemTypeLookup itemTypeLookup) { _libraryManager = libraryManager; _localization = localization; _dbContextFactory = dbContextFactory; + _fileSystem = fileSystem; + _logger = logger; + _itemTypeLookup = itemTypeLookup; } /// <inheritdoc /> @@ -71,13 +93,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2)); - await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false); + // People validation performs heavy database writes that contend with an active library scan. + // Defer it until the scan has finished; the task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + _logger.LogInformation("Skipping people validation because a library scan is currently running."); + return; + } - subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50)); + // Phase 1: Deduplicate and remove orphaned people (0-33%) var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { + IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3)); var dupQuery = context.Peoples .GroupBy(e => new { e.Name, e.PersonType }) .Where(e => e.Count() > 1) @@ -123,7 +151,113 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask ArrayPool<Guid[]>.Shared.Return(buffer); } + var peopleToDelete = await context.Peoples + .Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id))) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + _logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete); + subProgress.Report(100); } + + // 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); + + // Phase 3: Refresh images for people missing them (66-100%) + IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66)); + await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false); + + progress.Report(100); + } + + private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + const int PartitionSize = 100; + + var numPeople = 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) + .ConfigureAwait(false); + + _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); + + if (numPeople == 0) + { + progress.Report(100); + return; + } + + 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++; + } + + numComplete++; + progress.Report(100.0 * numComplete / numPeople); + } + + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); + } + } + + private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken) + { + try + { + if (_libraryManager.GetItemById(personId) is not Person item) + { + return false; + } + + var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary); + var hasOverview = !string.IsNullOrEmpty(item.Overview); + + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + }; + + await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId); + return false; + } } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 2885b89e3a..828bdd6859 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session _activeLiveStreamSessions.TryRemove(liveStreamId, out _); } } + else + { + liveStreamNeedsToBeClosed = true; + } if (liveStreamNeedsToBeClosed) { @@ -386,7 +390,7 @@ namespace Emby.Server.Implementations.Session { if (session is null) { - return; + return; } if (string.IsNullOrEmpty(info.MediaSourceId)) @@ -453,18 +457,6 @@ namespace Emby.Server.Implementations.Session session.PlayState.RepeatMode = info.RepeatMode; session.PlayState.PlaybackOrder = info.PlaybackOrder; session.PlaylistItemId = info.PlaylistItemId; - - var nowPlayingQueue = info.NowPlayingQueue; - - if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue)) - { - session.NowPlayingQueue = nowPlayingQueue; - - var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id); - session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos( - _libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }), - new DtoOptions(true)); - } } /// <summary> @@ -738,6 +730,31 @@ namespace Emby.Server.Implementations.Session } /// <summary> + /// Resolves the item whose user data (playback position, played status) should be updated + /// for a playback report. When an alternate version is played the client reports the displayed + /// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>. + /// </summary> + /// <param name="libraryItem">The now playing (displayed) item.</param> + /// <param name="mediaSourceId">The reported media source id.</param> + /// <returns>The item to track progress against.</returns> + private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId) + { + if (libraryItem is Video libraryVideo + && !string.IsNullOrEmpty(mediaSourceId) + && Guid.TryParse(mediaSourceId, out var mediaSourceItemId) + && !mediaSourceItemId.Equals(libraryVideo.Id)) + { + var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId); + if (versionItem is not null) + { + return versionItem; + } + } + + return libraryItem; + } + + /// <summary> /// Used to report that playback has started for an item. /// </summary> /// <param name="info">The info.</param> @@ -768,9 +785,10 @@ namespace Emby.Server.Implementations.Session if (libraryItem is not null) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - OnPlaybackStart(user, libraryItem); + OnPlaybackStart(user, progressItem); } } @@ -902,9 +920,10 @@ namespace Emby.Server.Implementations.Session // only update saved user data on actual check-ins, not automated ones if (libraryItem is not null && !isAutomated) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - OnPlaybackProgress(user, libraryItem, info); + OnPlaybackProgress(user, progressItem, info); } } @@ -964,6 +983,20 @@ namespace Emby.Server.Implementations.Session if (changed) { _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None); + + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) + if (data.Played == true && item is Video playedVideo) + { + playedVideo.PropagatePlayedState(user, true); + } + } + + if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue) + || (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue)) + { + _userDataManager.ResetPlaybackStreamSelections(user, item); } } @@ -1095,9 +1128,10 @@ namespace Emby.Server.Implementations.Session if (libraryItem is not null) { + var progressItem = GetProgressItem(libraryItem, info.MediaSourceId); foreach (var user in users) { - playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed); + playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed); } } @@ -1150,6 +1184,14 @@ namespace Emby.Server.Implementations.Session _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None); + // A completed version marks every alternate version played and clears their resume points, so the + // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions + // only persist while nothing has been completed yet.) + if (data.Played == true && item is Video playedVideo) + { + playedVideo.PropagatePlayedState(user, true); + } + return playedToCompletion; } @@ -1217,7 +1259,6 @@ namespace Emby.Server.Implementations.Session SupportsMediaControl = sessionInfo.SupportsMediaControl, SupportsRemoteControl = sessionInfo.SupportsRemoteControl, NowPlayingQueue = sessionInfo.NowPlayingQueue, - NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems, HasCustomDeviceName = sessionInfo.HasCustomDeviceName, PlaylistItemId = sessionInfo.PlaylistItemId, ServerId = sessionInfo.ServerId, diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 6a26e92e14..2582ed9df0 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session _logger.LogInformation("Lost {0} WebSockets.", lost.Count); foreach (var webSocket in lost) { - // TODO: handle session relative to the lost webSocket RemoveWebSocket(webSocket); + + // The connection stopped answering keep-alives, so a close frame will + // never arrive and the pending receive loop would hang forever, keeping + // the session (and e.g. its SyncPlay group membership) alive. Disposing + // the connection aborts the receive loop, which raises Closed and lets + // the session end normally. + try + { + webSocket.Dispose(); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint); + } } } } diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index c2e834ad58..38a0018a70 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -206,7 +206,8 @@ namespace Emby.Server.Implementations.SyncPlay foreach (var itemId in queue) { var item = _libraryManager.GetItemById(itemId); - if (!item.IsVisibleStandalone(user)) + + if (item is null || !item.IsVisibleStandalone(user)) { return false; } diff --git a/Emby.Server.Implementations/SystemManager.cs b/Emby.Server.Implementations/SystemManager.cs index d140426ddf..11a94648f8 100644 --- a/Emby.Server.Implementations/SystemManager.cs +++ b/Emby.Server.Implementations/SystemManager.cs @@ -89,11 +89,11 @@ public class SystemManager : ISystemManager .GetVirtualFolders() .Where(e => !string.IsNullOrWhiteSpace(e.ItemId)) // this should not be null but for some users it is. .Select(e => new LibraryStorageInfo() - { - Id = Guid.Parse(e.ItemId), - Name = e.Name, - Folders = e.Locations.Select(f => StorageHelper.GetFreeSpaceOf(f)).ToArray() - }); + { + Id = Guid.Parse(e.ItemId), + Name = e.Name, + Folders = e.Locations.Select(f => StorageHelper.GetFreeSpaceOf(f)).ToArray() + }); return new SystemStorageInfo() { diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 535dc01a31..459ad1a17e 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Jellyfin.Data; using Jellyfin.Data.Enums; @@ -136,11 +137,15 @@ namespace Emby.Server.Implementations.TV if (nextEpisode is not null) { + // The last played date and the version that was actually played live on the version item's user data + // The played state propagated to the sibling versions carries no date + var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user); + nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion); + DateTime lastWatchedDate = DateTime.MinValue; if (result.LastWatched is not null) { - var userData = _userDataManager.GetUserData(user, result.LastWatched); - lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1); + lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); } nextUpList.Add((lastWatchedDate, nextEpisode)); @@ -152,11 +157,13 @@ namespace Emby.Server.Implementations.TV if (nextPlayedEpisode is not null) { + var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user); + nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion); + DateTime rewatchLastWatchedDate = DateTime.MinValue; if (result.LastWatchedForRewatching is not null) { - var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching); - rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1); + rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1); } nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode)); @@ -219,10 +226,13 @@ namespace Emby.Server.Implementations.TV if (nextEpisode is not null && !includeResumable) { - var userData = _userDataManager.GetUserData(user, nextEpisode); - if (userData?.PlaybackPositionTicks > 0) + // The resume progress may live on an alternate version + foreach (var version in nextEpisode.GetAllVersions()) { - return null; + if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0) + { + return null; + } } } @@ -237,6 +247,74 @@ namespace Emby.Server.Implementations.TV return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true); } + /// <summary> + /// Gets the version of the last watched episode that was actually played, together with its last played date. + /// The version that was played carries the most recent LastPlayedDate. + /// dates. + /// </summary> + /// <param name="lastWatched">The last watched episode (any version).</param> + /// <param name="user">The user.</param> + /// <returns>The played version and its last played date.</returns> + private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user) + { + if (lastWatched is not Video lastWatchedVideo) + { + return (null, null); + } + + var versions = lastWatchedVideo.GetAllVersions(); + var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user); + + var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed( + versions, + version => userDataByVersion.GetValueOrDefault(version.Id), + data => data.LastPlayedDate.HasValue); + + return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate); + } + + /// <summary> + /// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name, + /// so Next Up continues in the version the user has been watching instead of falling back to the primary. + /// </summary> + /// <param name="nextEpisode">The determined next episode (a primary).</param> + /// <param name="lastWatched">The last watched episode.</param> + /// <param name="playedVersion">The version of the last watched episode that was played.</param> + /// <returns>The matching version of the next episode, or the episode itself.</returns> + private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion) + { + // No version preference, or the primary was played + if (lastWatched is not Video lastWatchedVideo + || playedVersion is null + || !playedVersion.PrimaryVersionId.HasValue) + { + return nextEpisode; + } + + // Match by version name + var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture); + var playedVersionName = lastWatchedVideo.GetMediaSources(false) + .FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name; + + if (string.IsNullOrEmpty(playedVersionName)) + { + return nextEpisode; + } + + var matchingSource = nextEpisode.GetMediaSources(false) + .FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase)); + + if (matchingSource is not null + && Guid.TryParse(matchingSource.Id, out var matchingId) + && !matchingId.Equals(nextEpisode.Id) + && _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion) + { + return matchingVersion; + } + + return nextEpisode; + } + private static string GetUniqueSeriesKey(Series series) { return series.GetPresentationUniqueKey(); diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 67b77a112d..6a60f7f5f6 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates /// </summary> public class InstallationManager : IInstallationManager { + private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']); + /// <summary> /// The logger. /// </summary> @@ -521,48 +524,68 @@ namespace Emby.Server.Implementations.Updates return; } + if (!IsValidPackageDirectoryName(package.Name)) + { + _logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name); + throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name."); + } + // Always override the passed-in target (which is a file) and figure it out again string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - // CA5351: Do Not Use Broken Cryptographic Algorithms -#pragma warning disable CA5351 - cancellationToken.ThrowIfCancellationRequested(); - - var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); - if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) + var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath)); + var resolvedTarget = Path.GetFullPath(targetDir); + if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { _logger.LogError( - "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", + "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.", package.Name, - package.Checksum, - hash); - throw new InvalidDataException("The checksum of the received data doesn't match."); + resolvedTarget, + pluginsRoot); + throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); } - // Version folder as they cannot be overwritten in Windows. - targetDir += "_" + package.Version; - - if (Directory.Exists(targetDir)) + 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)) { - try + // CA5351: Do Not Use Broken Cryptographic Algorithms +#pragma warning disable CA5351 + cancellationToken.ThrowIfCancellationRequested(); + + var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); + if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) { - Directory.Delete(targetDir, true); + _logger.LogError( + "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", + package.Name, + package.Checksum, + hash); + throw new InvalidDataException("The checksum of the received data doesn't match."); } + + // Version folder as they cannot be overwritten in Windows. + targetDir += "_" + package.Version; + + if (Directory.Exists(targetDir)) + { + try + { + Directory.Delete(targetDir, true); + } #pragma warning disable CA1031 // Do not catch general exception types - catch + catch #pragma warning restore CA1031 // Do not catch general exception types - { - // Ignore any exceptions. + { + // Ignore any exceptions. + } } - } - stream.Position = 0; - await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken); + stream.Position = 0; + await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken).ConfigureAwait(false); + } // Ensure we create one or populate existing ones with missing data. await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); @@ -570,6 +593,26 @@ namespace Emby.Server.Implementations.Updates _pluginManager.ImportPluginFrom(targetDir); } + private static bool IsValidPackageDirectoryName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal)) + { + return false; + } + + if (name.IndexOfAny(InvalidPackageNameChars) >= 0) + { + return false; + } + + return true; + } + private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) 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/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 590bd05da4..77bb6ee7e7 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -91,18 +91,18 @@ public class AudioController : BaseJellyfinApiController [ProducesAudioFile] public async Task<ActionResult> GetAudioStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -112,7 +112,7 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -131,8 +131,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -255,18 +255,18 @@ public class AudioController : BaseJellyfinApiController [ProducesAudioFile] public async Task<ActionResult> GetAudioStreamByContainer( [FromRoute, Required] Guid itemId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -276,7 +276,7 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -295,8 +295,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 227487b390..aa2b24c1e7 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -88,7 +88,7 @@ public class CollectionController : BaseJellyfinApiController [FromRoute, Required] Guid collectionId, [FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids) { - await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index eadb8c9855..2bbfeb40b8 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Dtos; using Jellyfin.Data.Queries; using MediaBrowser.Common.Api; @@ -112,28 +116,31 @@ public class DevicesController : BaseJellyfinApiController } /// <summary> - /// Deletes a device. + /// Deletes devices. /// </summary> - /// <param name="id">Device Id.</param> + /// <param name="id">Device Ids.</param> /// <response code="204">Device deleted.</response> - /// <response code="404">Device not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + /// <response code="400">A requested device is invalid.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="BadRequestResult"/> if a requested device is invalid.</returns> [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id) + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task<ActionResult> DeleteDevice([FromQuery] string[] id) { - var existingDevice = _deviceManager.GetDevice(id); - if (existingDevice is null) + var devices = id.Select(_deviceManager.GetDevice).ToArray(); + if (devices.Any(f => f is null)) { - return NotFound(); + return BadRequest(); } - var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }); - - foreach (var session in sessions.Items) + foreach (var device in devices) { - await _sessionManager.Logout(session).ConfigureAwait(false); + var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }); + + foreach (var session in sessions.Items) + { + await _sessionManager.Logout(session).ConfigureAwait(false); + } } return NoContent(); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index c059f5880d..838f48949d 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -167,18 +167,18 @@ public class DynamicHlsController : BaseJellyfinApiController [ProducesPlaylistFile] public async Task<ActionResult> GetLiveHlsStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -188,7 +188,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -207,8 +207,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -413,12 +413,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery, Required] string mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -428,7 +428,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -449,8 +449,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -586,12 +586,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery, Required] string mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -602,7 +602,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -621,8 +621,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -753,12 +753,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -768,7 +768,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -789,8 +789,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -922,12 +922,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -938,7 +938,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -957,8 +957,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1092,7 +1092,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery, Required] long runtimeTicks, [FromQuery, Required] long actualSegmentLengthTicks, [FromQuery] bool? @static, @@ -1100,12 +1100,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -1115,7 +1115,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -1136,8 +1136,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -1274,7 +1274,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery, Required] long runtimeTicks, [FromQuery, Required] long actualSegmentLengthTicks, [FromQuery] bool? @static, @@ -1282,12 +1282,12 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -1298,7 +1298,7 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -1317,8 +1317,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index b5365cd632..c61ee45830 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) { // TODO: Deprecate with new iOS app - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } @@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) { - var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) - || Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) + var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null + || !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) { return BadRequest("Invalid segment."); } @@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController [FromRoute, Required] string segmentId, [FromRoute, Required] string segmentContainer) { - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); - - file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } - var normalizedPlaylistId = playlistId; - + var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); // Add . to start of segment container for future use. segmentContainer = segmentContainer.Insert(0, "."); @@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController var pathExtension = Path.GetExtension(path); if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) - && path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase)) + && path.Contains(playlistId, StringComparison.OrdinalIgnoreCase)) { playlistPath = path; break; @@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController : GetFileResult(file, playlistPath); } + private string? ValidateTranscodePath(string filename) + { + var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath())); + var file = Path.GetFullPath(filename, transcodePath); + // Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass. + if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return file; + } + private ActionResult GetFileResult(string path, string playlistPath) { var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index ae792142b4..52d8b4dad1 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -2049,7 +2049,7 @@ public class ImageController : BaseJellyfinApiController } // Check If-Modified-Since header for time-based validation - if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader)) + if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], CultureInfo.InvariantCulture, out var ifModifiedSinceHeader)) { // Return 304 if the image has not been modified since the client's cached version if (dateImageModified <= ifModifiedSinceHeader) diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 4d697ab854..e26264d292 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -279,6 +279,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.DateCreated = NormalizeDateTime(request.DateCreated.Value); } + if (request.SeriesName is not null && item is IHasSeries hasSeries) + { + hasSeries.SeriesName = request.SeriesName; + } + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; item.ProductionYear = request.ProductionYear; @@ -288,7 +293,7 @@ public class ItemUpdateController : BaseJellyfinApiController item.CustomRating = request.CustomRating; var currentTags = item.Tags; - var newTags = request.Tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + 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; @@ -297,6 +302,8 @@ public class ItemUpdateController : BaseJellyfinApiController { foreach (var season in rseries.Children.OfType<Season>()) { + season.SeriesName = rseries.Name; + if (!season.LockedFields.Contains(MetadataField.OfficialRating)) { season.OfficialRating = request.OfficialRating; @@ -314,6 +321,8 @@ public class ItemUpdateController : BaseJellyfinApiController foreach (var ep in season.Children.OfType<Episode>()) { + ep.SeriesName = rseries.Name; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) { ep.OfficialRating = request.OfficialRating; diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 43e4737694..385795e9f7 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; @@ -14,6 +15,7 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -41,6 +43,7 @@ public class ItemsController : BaseJellyfinApiController private readonly ILogger<ItemsController> _logger; private readonly ISessionManager _sessionManager; private readonly IUserDataManager _userDataRepository; + private readonly ISearchManager _searchManager; /// <summary> /// Initializes a new instance of the <see cref="ItemsController"/> class. @@ -52,6 +55,7 @@ public class ItemsController : BaseJellyfinApiController /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param> public ItemsController( IUserManager userManager, ILibraryManager libraryManager, @@ -59,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController IDtoService dtoService, ILogger<ItemsController> logger, ISessionManager sessionManager, - IUserDataManager userDataRepository) + IUserDataManager userDataRepository, + ISearchManager searchManager) { _userManager = userManager; _libraryManager = libraryManager; @@ -68,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController _logger = logger; _sessionManager = sessionManager; _userDataRepository = userDataRepository; + _searchManager = searchManager; } /// <summary> @@ -271,7 +277,7 @@ public class ItemsController : BaseJellyfinApiController && user.GetPreference(PreferenceKind.AllowedTags).Length != 0 && !fields.Contains(ItemFields.Tags)) { - fields = [..fields, ItemFields.Tags]; + fields = [.. fields, ItemFields.Tags]; } var dtoOptions = new DtoOptions { Fields = fields } @@ -280,10 +286,23 @@ public class ItemsController : BaseJellyfinApiController var item = _libraryManager.GetParentItem(parentId, userId); QueryResult<BaseItem> result; + Guid[] linkedChildAncestorIds = []; + + includeItemTypes ??= []; if (includeItemTypes.Length == 1 - && includeItemTypes[0] == BaseItemKind.BoxSet - && item is not BoxSet) + && (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist) + && item is not BoxSet + && item is not Playlist) { + var itemCollectionType = item is IHasCollectionType hct ? hct.CollectionType : null; + var targetCollectionType = includeItemTypes[0] == BaseItemKind.BoxSet + ? CollectionType.boxsets + : CollectionType.playlists; + if (parentId.HasValue && item is not UserRootFolder && itemCollectionType != targetCollectionType) + { + linkedChildAncestorIds = [parentId.Value]; + } + parentId = null; item = _libraryManager.GetUserRootFolder(); } @@ -302,22 +321,22 @@ public class ItemsController : BaseJellyfinApiController if (collectionType == CollectionType.playlists) { recursive = true; - includeItemTypes = new[] { BaseItemKind.Playlist }; + includeItemTypes = [BaseItemKind.Playlist]; } - else if (folder is ICollectionFolder) + else if (folder is ICollectionFolder && includeItemTypes.Length == 0) { - // When the client doesn't specify recursive/includeItemTypes, force the query - // through the database path where all filters (IsHD, genres, etc.) are applied. - recursive ??= true; - if (includeItemTypes.Length == 0) + includeItemTypes = collectionType switch { - includeItemTypes = collectionType switch - { - CollectionType.boxsets => [BaseItemKind.BoxSet], - null => [BaseItemKind.Movie, BaseItemKind.Series], - _ => [] - }; - } + CollectionType.boxsets => [BaseItemKind.BoxSet], + _ => [] + }; + } + + // includeItemTypes on a library lists its contents recursively rather than just its + // immediate children, so default to a recursive query when the client didn't choose. + if (folder is ICollectionFolder && includeItemTypes.Length > 0) + { + recursive ??= true; } if (item is not UserRootFolder @@ -330,217 +349,273 @@ public class ItemsController : BaseJellyfinApiController return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); } - if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) + // Build the query up front so the dispatch below can decide the path from it. + // Use search providers when searchTerm is provided. Providers return only IDs and scores; + // items are loaded server-side via folder.GetItems below, which applies user-access filtering. + Dictionary<Guid, float>? searchResultScores = null; + Guid[] itemIds = ids; + + if (!string.IsNullOrWhiteSpace(searchTerm)) { - var query = new InternalItemsQuery(user) + var searchProviderQuery = new SearchProviderQuery { - IsPlayed = isPlayed, - MediaTypes = mediaTypes, + SearchTerm = searchTerm, + UserId = userId, IncludeItemTypes = includeItemTypes, ExcludeItemTypes = excludeItemTypes, - Recursive = recursive ?? false, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - IsFavorite = isFavorite, - Limit = limit, - StartIndex = startIndex, - IsMissing = isMissing, - IsUnaired = isUnaired, - CollapseBoxSetItems = collapseBoxSetItems, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - HasImdbId = hasImdbId, - IsPlaceHolder = isPlaceHolder, - IsLocked = isLocked, - MinWidth = minWidth, - MinHeight = minHeight, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - Is3D = is3D, - HasTvdbId = hasTvdbId, - HasTmdbId = hasTmdbId, - IsMovie = isMovie, - IsSeries = isSeries, - IsNews = isNews, - IsKids = isKids, - IsSports = isSports, - HasOverview = hasOverview, - HasOfficialRating = hasOfficialRating, - HasParentalRating = hasParentalRating, - HasSpecialFeature = hasSpecialFeature, - HasSubtitles = hasSubtitles, - HasThemeSong = hasThemeSong, - HasThemeVideo = hasThemeVideo, - HasTrailer = hasTrailer, - IsHD = isHd, - Is4K = is4K, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - ArtistIds = artistIds, - AlbumArtistIds = albumArtistIds, - ContributingArtistIds = contributingArtistIds, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - ImageTypes = imageTypes, - VideoTypes = videoTypes, - AdjacentTo = adjacentTo, - ItemIds = ids, - MinCommunityRating = minCommunityRating, - MinCriticRating = minCriticRating, - ParentId = parentId ?? Guid.Empty, - IndexNumber = indexNumber, - ParentIndexNumber = parentIndexNumber, - EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = excludeItemIds, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), - MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), - MinPremiereDate = minPremiereDate?.ToUniversalTime(), - MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), - AudioLanguages = audioLanguages, - SubtitleLanguages = subtitleLanguages, + MediaTypes = mediaTypes, + Limit = limit.HasValue ? limit.Value * 3 : null, + ParentId = parentId }; - if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false); + if (searchResults.Count > 0) { - query.CollapseBoxSetItems = false; + searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score); + itemIds = ids.Length > 0 + ? ids.Concat(searchResultScores.Keys).Distinct().ToArray() + : searchResultScores.Keys.ToArray(); } + } + + var query = new InternalItemsQuery(user) + { + IsPlayed = isPlayed, + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + Recursive = recursive ?? false, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsFavorite = isFavorite, + Limit = searchResultScores is null ? limit : null, + StartIndex = searchResultScores is null ? startIndex : null, + IsMissing = isMissing, + IsUnaired = isUnaired, + CollapseBoxSetItems = collapseBoxSetItems, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + HasImdbId = hasImdbId, + IsPlaceHolder = isPlaceHolder, + IsLocked = isLocked, + MinWidth = minWidth, + MinHeight = minHeight, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + Is3D = is3D, + HasTvdbId = hasTvdbId, + HasTmdbId = hasTmdbId, + IsMovie = isMovie, + IsSeries = isSeries, + IsNews = isNews, + IsKids = isKids, + IsSports = isSports, + HasOverview = hasOverview, + HasOfficialRating = hasOfficialRating, + HasParentalRating = hasParentalRating, + HasSpecialFeature = hasSpecialFeature, + HasSubtitles = hasSubtitles, + HasThemeSong = hasThemeSong, + HasThemeVideo = hasThemeVideo, + HasTrailer = hasTrailer, + IsHD = isHd, + Is4K = is4K, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + ArtistIds = artistIds, + AlbumArtistIds = albumArtistIds, + ContributingArtistIds = contributingArtistIds, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + ImageTypes = imageTypes, + VideoTypes = videoTypes, + AdjacentTo = adjacentTo, + ItemIds = itemIds, + MinCommunityRating = minCommunityRating, + MinCriticRating = minCriticRating, + ParentId = parentId ?? Guid.Empty, + IndexNumber = indexNumber, + ParentIndexNumber = parentIndexNumber, + EnableTotalRecordCount = enableTotalRecordCount, + ExcludeItemIds = excludeItemIds, + DtoOptions = dtoOptions, + SearchTerm = searchResultScores is null ? searchTerm : null, + MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), + MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), + MinPremiereDate = minPremiereDate?.ToUniversalTime(), + MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + AudioLanguages = audioLanguages, + SubtitleLanguages = subtitleLanguages, + LinkedChildAncestorIds = linkedChildAncestorIds, + }; - if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + { + query.CollapseBoxSetItems = false; + } + + if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + { + if (query.HasSubtitles.Value) { - if (query.HasSubtitles.Value) - { - // if we check for specific subtitles we don't need a separate check for subtitle existence - query.HasSubtitles = null; - } - else - { - // if we search for items without subtitles, we don't need to check for subtitles of a specific language - query.SubtitleLanguages = []; - } + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; } - - // for filter values that rely on media streams, we need to include alternative and linked versions - if (query.HasSubtitles.HasValue - || query.SubtitleLanguages.Count > 0 - || query.AudioLanguages.Count > 0 - || query.Is3D.HasValue - || query.IsHD.HasValue - || query.Is4K.HasValue - || query.VideoTypes.Length > 0 - ) + else { - query.IncludeOwnedItems = true; + // if we search for items without subtitles, we don't need to check for subtitles of a specific language + query.SubtitleLanguages = []; } + } - query.ApplyFilters(filters); + // for filter values that rely on media streams, we need to include alternative and linked versions + if (query.HasSubtitles.HasValue + || query.SubtitleLanguages.Count > 0 + || query.AudioLanguages.Count > 0 + || query.Is3D.HasValue + || query.IsHD.HasValue + || query.Is4K.HasValue + || query.VideoTypes.Length > 0 + ) + { + query.IncludeOwnedItems = true; + } - // Filter by Series Status - if (seriesStatus.Length != 0) - { - query.SeriesStatuses = seriesStatus; - } + query.ApplyFilters(filters); - // Exclude Blocked Unrated Items - var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); - if (blockedUnratedItems is not null) - { - query.BlockUnratedItems = blockedUnratedItems; - } + // Filter by Series Status + if (seriesStatus.Length != 0) + { + query.SeriesStatuses = seriesStatus; + } - // ExcludeLocationTypes - if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) - { - query.IsVirtualItem = false; - } + // Exclude Blocked Unrated Items + var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); + if (blockedUnratedItems is not null) + { + query.BlockUnratedItems = blockedUnratedItems; + } - if (locationTypes.Length > 0 && locationTypes.Length < 4) - { - query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); - } + // ExcludeLocationTypes + if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) + { + query.IsVirtualItem = false; + } - // Min official rating - if (!string.IsNullOrWhiteSpace(minOfficialRating)) - { - query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); - } + if (locationTypes.Length > 0 && locationTypes.Length < 4) + { + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); + } - // Max official rating - if (!string.IsNullOrWhiteSpace(maxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); - } + // Min official rating + if (!string.IsNullOrWhiteSpace(minOfficialRating)) + { + query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); + } + + // Max official rating + if (!string.IsNullOrWhiteSpace(maxOfficialRating)) + { + query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); + } - // Artists - if (artists.Length != 0) + // Artists + if (artists.Length != 0) + { + query.ArtistIds = artists.Select(i => { - query.ArtistIds = artists.Select(i => + try { - try - { - return _libraryManager.GetArtist(i, new DtoOptions(false)); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } + return _libraryManager.GetArtist(i, new DtoOptions(false)); + } + catch + { + return null; + } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } + + if (albumIds.Length != 0) + { + query.AlbumIds = albumIds; + } - if (albumIds.Length != 0) + // Albums + if (albums.Length != 0) + { + query.AlbumIds = albums.SelectMany(i => { - query.AlbumIds = albumIds; - } + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 }); + }).ToArray(); + } - // Albums - if (albums.Length != 0) + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - query.AlbumIds = albums.SelectMany(i => + try { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); - }).ToArray(); - } + return _libraryManager.GetStudio(i); + } + catch + { + return null; + } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // Studios - if (studios.Length != 0) + // Apply default sorting if none requested + if (query.OrderBy.Count == 0) + { + // Albums by artist + if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)]; } + } + + query.Parent = null; - // Apply default sorting if none requested - if (query.OrderBy.Count == 0) + // At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters) + { + // folder.GetItems applies user-access filtering via the InternalItemsQuery's User. + result = folder.GetItems(query); + if (searchResultScores is not null && searchResultScores.Count > 0) { - // Albums by artist - if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) + var orderedItems = result.Items + .OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f)) + .ThenBy(item => item.SortName) + .ToArray(); + + var totalCount = orderedItems.Length; + if (startIndex.HasValue && startIndex.Value > 0) { - query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + orderedItems = orderedItems.Skip(startIndex.Value).ToArray(); } - } - query.Parent = null; - result = folder.GetItems(query); + if (limit.HasValue) + { + orderedItems = orderedItems.Take(limit.Value).ToArray(); + } + + return new QueryResult<BaseItemDto>( + startIndex, + totalCount, + _dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user)); + } } else { @@ -888,15 +963,21 @@ public class ItemsController : BaseJellyfinApiController var excludeItemIds = Array.Empty<Guid>(); if (excludeActiveSessions) { + // NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played + // alternate version's own id. Expand each active session to every version id so an in-progress + // alternate is excluded too, instead of leaking back into the resume list. excludeItemIds = _sessionManager.Sessions .Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null) - .Select(s => s.NowPlayingItem.Id) + .SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video + ? video.GetAllVersions().Select(v => v.Id) + : [s.NowPlayingItem.Id]) + .Distinct() .ToArray(); } var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, + OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)], IsResumable = true, StartIndex = startIndex, Limit = limit, @@ -906,6 +987,7 @@ public class ItemsController : BaseJellyfinApiController MediaTypes = mediaTypes, IsVirtualItem = false, CollapseBoxSetItems = false, + IncludeOwnedItems = true, EnableTotalRecordCount = enableTotalRecordCount, AncestorIds = ancestorIds, IncludeItemTypes = includeItemTypes, diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index abf27b7702..39a6fbace8 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -17,6 +17,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -50,6 +51,7 @@ public class LibraryController : BaseJellyfinApiController private readonly ISimilarItemsManager _similarItemsManager; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ICollectionManager _collectionManager; private readonly IDtoService _dtoService; private readonly IActivityManager _activityManager; private readonly ILocalizationManager _localization; @@ -64,6 +66,7 @@ public class LibraryController : BaseJellyfinApiController /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> @@ -75,6 +78,7 @@ public class LibraryController : BaseJellyfinApiController ISimilarItemsManager similarItemsManager, ILibraryManager libraryManager, IUserManager userManager, + ICollectionManager collectionManager, IDtoService dtoService, IActivityManager activityManager, ILocalizationManager localization, @@ -86,6 +90,7 @@ public class LibraryController : BaseJellyfinApiController _similarItemsManager = similarItemsManager; _libraryManager = libraryManager; _userManager = userManager; + _collectionManager = collectionManager; _dtoService = dtoService; _activityManager = activityManager; _localization = localization; @@ -114,7 +119,18 @@ public class LibraryController : BaseJellyfinApiController return NotFound(); } - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); + var filePath = item.Path; + if (item.IsFileProtocol) + { + // PhysicalFile does not work well with symlinks at the moment. + var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true); + if (resolved is not null && resolved.Exists) + { + filePath = resolved.FullName; + } + } + + return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), true); } /// <summary> @@ -705,6 +721,72 @@ public class LibraryController : BaseJellyfinApiController } /// <summary> + /// Gets the collections that include the specified item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="startIndex">Optional. The index of the first record in the output.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <response code="200">Collections returned.</response> + /// <response code="401">User context missing.</response> + /// <response code="404">Item not found.</response> + /// <returns>The collections that contain the requested item.</returns> + [HttpGet("Items/{itemId}/Collections")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetItemCollections( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + + if (user is null) + { + return Unauthorized(); + } + + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + + var dtoOptions = new DtoOptions { Fields = fields }; + + var visibleCollections = _collectionManager + .GetCollectionsContainingItem(user, item.Id) + .OrderBy(i => i.SortName, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + IEnumerable<BaseItem> pagedCollections = visibleCollections; + if (startIndex.HasValue) + { + pagedCollections = pagedCollections.Skip(startIndex.Value); + } + + if (limit.HasValue) + { + pagedCollections = pagedCollections.Take(limit.Value); + } + + var dtos = _dtoService.GetBaseItemDtos(pagedCollections.ToList(), dtoOptions, user); + + return new QueryResult<BaseItemDto>( + startIndex, + visibleCollections.Count, + dtos); + } + + /// <summary> /// Gets similar items. /// </summary> /// <param name="itemId">The item id.</param> diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 113298c251..1c570daf21 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController { if (!string.IsNullOrEmpty(pw)) { - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); + listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw))); } return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index f22ac0b73a..ac7c091f85 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController Request.HttpContext.GetNormalizedRemoteIP()); } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); } if (autoOpenLiveStream.Value) diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 50d34d0656..a1f2fe7ce7 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,17 +1,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -30,27 +26,23 @@ namespace Jellyfin.Api.Controllers; public class MoviesController : BaseJellyfinApiController { private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISimilarItemsManager _similarItemsManager; /// <summary> /// Initializes a new instance of the <see cref="MoviesController"/> class. /// </summary> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param> public MoviesController( IUserManager userManager, - ILibraryManager libraryManager, IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) + ISimilarItemsManager similarItemsManager) { _userManager = userManager; - _libraryManager = libraryManager; _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; + _similarItemsManager = similarItemsManager; } /// <summary> @@ -61,15 +53,17 @@ public class MoviesController : BaseJellyfinApiController /// <param name="fields">Optional. The fields to return.</param> /// <param name="categoryLimit">The max number of categories to return.</param> /// <param name="itemLimit">The max number of items to return per category.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <response code="200">Movie recommendations returned.</response> /// <returns>The list of movie recommendations.</returns> [HttpGet("Recommendations")] - public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( + public async Task<ActionResult<IEnumerable<RecommendationDto>>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) + [FromQuery] int itemLimit = 8, + CancellationToken cancellationToken = default) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -77,251 +71,16 @@ public class MoviesController : BaseJellyfinApiController : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields }; - var categories = new List<RecommendationDto>(); + var recommendations = await _similarItemsManager + .GetMovieRecommendationsAsync(user, parentId ?? Guid.Empty, categoryLimit, itemLimit, dtoOptions, cancellationToken) + .ConfigureAwait(false); - var parentIdGuid = parentId ?? Guid.Empty; - - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] - { - BaseItemKind.Movie, - // nameof(Trailer), - // nameof(LiveTvProgram) - }, - // IsMovie = true - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - DtoOptions = dtoOptions - }; - - var recentlyPlayedMovies = _libraryManager.GetItemList(query); - - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList(); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); - - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); - - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); - - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); - - var categoryTypes = new List<IEnumerator<RecommendationDto>> - { - // Give this extra weight - similarToRecentlyPlayed, - similarToRecentlyPlayed, - - // Give this extra weight - similarToLiked, - similarToLiked, - hasDirectorFromRecentlyPlayed, - hasActorFromRecentlyPlayed - }; - - while (categories.Count < categoryLimit) + return Ok(recommendations.Select(r => new RecommendationDto { - var allEmpty = true; - - foreach (var category in categoryTypes) - { - if (category.MoveNext()) - { - categories.Add(category.Current); - allEmpty = false; - - if (categories.Count >= categoryLimit) - { - break; - } - } - } - - if (allEmpty) - { - break; - } - } - - return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); - } - - private IEnumerable<RecommendationDto> GetWithDirector( - User? user, - IEnumerable<string> names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList( - new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var item in baselineItems) - { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); - - if (similar.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director }) - { - MaxListOrder = 3 - }); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } - - private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty<string>())); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + BaselineItemName = r.BaselineItemName, + CategoryId = r.CategoryId, + RecommendationType = r.RecommendationType, + Items = _dtoService.GetBaseItemDtos(r.Items, dtoOptions, user) + })); } } diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 79e6536fb6..2c84fde972 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -226,16 +226,35 @@ public class PluginsController : BaseJellyfinApiController return NotFound(); } - var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty); - if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath)) + string? imagePath = plugin.Manifest.ImagePath; + if (!string.IsNullOrWhiteSpace(imagePath)) { - return NotFound(); + var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path)); + imagePath = Path.GetFullPath(imagePath, pluginPath); + // Require a separator after the plugin path so a sibling like "<pluginPath>-evil" can't pass. + if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false) + { + return NotFound(); + } + + Response.Headers.ContentDisposition = "attachment"; + return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); } - Response.Headers.ContentDisposition = "attachment"; + var resourceName = plugin.Manifest.ImageResourceName; + if (!string.IsNullOrEmpty(resourceName) && plugin.Instance is not null) + { + var stream = plugin.Instance.GetType().Assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + return NotFound(); + } + + Response.Headers.ContentDisposition = "attachment"; + return File(stream, MimeTypes.GetMimeType(resourceName)); + } - imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); - return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + return NotFound(); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index ecf2335ba0..b03cb88e75 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; +using System.Threading.Tasks; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -29,7 +30,7 @@ namespace Jellyfin.Api.Controllers; [Authorize] public class SearchController : BaseJellyfinApiController { - private readonly ISearchEngine _searchEngine; + private readonly ISearchManager _searchManager; private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IImageProcessor _imageProcessor; @@ -37,17 +38,17 @@ public class SearchController : BaseJellyfinApiController /// <summary> /// Initializes a new instance of the <see cref="SearchController"/> class. /// </summary> - /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param> + /// <param name="searchManager">Instance of <see cref="ISearchManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param> public SearchController( - ISearchEngine searchEngine, + ISearchManager searchManager, ILibraryManager libraryManager, IDtoService dtoService, IImageProcessor imageProcessor) { - _searchEngine = searchEngine; + _searchManager = searchManager; _libraryManager = libraryManager; _dtoService = dtoService; _imageProcessor = imageProcessor; @@ -79,7 +80,7 @@ public class SearchController : BaseJellyfinApiController [HttpGet] [Description("Gets search hints based on a search term")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<SearchHintResult> GetSearchHints( + public async Task<ActionResult<SearchHintResult>> GetSearchHints( [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] Guid? userId, @@ -100,7 +101,7 @@ public class SearchController : BaseJellyfinApiController [FromQuery] bool includeArtists = true) { userId = RequestHelpers.GetUserId(User, userId); - var result = _searchEngine.GetSearchHints(new SearchQuery + var result = await _searchManager.GetSearchHintsAsync(new SearchQuery { Limit = limit, SearchTerm = searchTerm, @@ -121,7 +122,7 @@ public class SearchController : BaseJellyfinApiController IsNews = isNews, IsSeries = isSeries, IsSports = isSports - }); + }).ConfigureAwait(false); return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 4373a46adc..fa6d9efe36 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -145,12 +145,14 @@ public class StartupController : BaseJellyfinApiController return BadRequest("Password must not be empty"); } - if (startupUserDto.Name is not null) + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + +#pragma warning disable CA1309 // Use ordinal string comparison + if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase)) { - user.Username = startupUserDto.Name; + await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false); } - - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); +#pragma warning restore CA1309 // Use ordinal string comparison if (!string.IsNullOrEmpty(startupUserDto.Password)) { diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 121db66858..6bddd85337 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -122,6 +122,7 @@ public class TrailersController : BaseJellyfinApiController /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetItems with includeItemTypes=Trailer instead.")] public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers( [FromQuery] Guid? userId, [FromQuery] string? maxOfficialRating, diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index e45a100b77..6b0f10e02a 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -232,7 +232,7 @@ public class TvShowsController : BaseJellyfinApiController if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { - var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value); + var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value, user); if (item is not Season seasonItem) { return NotFound("No season exists with Id " + seasonId); @@ -242,7 +242,7 @@ public class TvShowsController : BaseJellyfinApiController } else if (season.HasValue) // Season number was supplied. Get episodes by season number { - var series = _libraryManager.GetItemById<Series>(seriesId); + var series = _libraryManager.GetItemById<Series>(seriesId, user); if (series is null) { return NotFound("Series not found"); @@ -258,7 +258,7 @@ public class TvShowsController : BaseJellyfinApiController } else // No season number or season id was supplied. Returning all episodes. { - if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series) + if (_libraryManager.GetItemById<BaseItem>(seriesId, user) is not Series series) { return NotFound("Series not found"); } @@ -277,8 +277,15 @@ public class TvShowsController : BaseJellyfinApiController if (startItemId.HasValue) { + // The start item may be an alternate version, which is not part of the episode listing; start from its primary episode instead. + var startId = startItemId.Value; + if (_libraryManager.GetItemById<Video>(startId)?.PrimaryVersionId is { } primaryVersionId) + { + startId = primaryVersionId; + } + episodes = episodes - .SkipWhile(i => !startItemId.Value.Equals(i.Id)) + .SkipWhile(i => !startId.Equals(i.Id)) .ToList(); } diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index d4e9b234c5..e53d15acfd 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, [FromQuery] Guid? userId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] int? maxAudioChannels, [FromQuery] int? transcodingAudioChannels, [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] long? startTimeTicks, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer, [FromQuery] MediaStreamProtocol? transcodingProtocol, [FromQuery] int? maxAudioSampleRate, [FromQuery] int? maxAudioBitDepth, @@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController Request.HttpContext.GetNormalizedRemoteIP()); } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id); foreach (var source in info.MediaSources) { diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 779186942a..a718035528 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -429,14 +429,8 @@ public class UserLibraryController : BaseJellyfinApiController } var dtoOptions = new DtoOptions(); - if (item is IHasTrailers hasTrailers) - { - var trailers = hasTrailers.LocalTrailers; - return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); - } - return Ok(item.GetExtras() - .Where(e => e.ExtraType == ExtraType.Trailer) + return Ok(item.GetExtras([ExtraType.Trailer], user) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); } @@ -487,7 +481,7 @@ public class UserLibraryController : BaseJellyfinApiController var dtoOptions = new DtoOptions(); return Ok(item - .GetExtras() + .GetExtras(user) .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); } @@ -577,7 +571,7 @@ public class UserLibraryController : BaseJellyfinApiController var item = tuple.Item2[0]; var childCount = 0; - if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum || tuple.Item1 is Series)) + if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum)) { item = tuple.Item1; childCount = tuple.Item2.Count; diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index c1d06bad36..8b359c48af 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -88,7 +88,7 @@ public class UserViewsController : BaseJellyfinApiController var folders = _userViewManager.GetUserViews(query); var dtoOptions = new DtoOptions(); - dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; + dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 2c2cbf1ec6..29a92cdb90 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -116,7 +116,7 @@ public class VideosController : BaseJellyfinApiController BaseItemDto[] items; if (item is Video video) { - items = video.GetAdditionalParts() + items = video.GetAdditionalParts(user) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) .ToArray(); } @@ -317,18 +317,18 @@ public class VideosController : BaseJellyfinApiController [ProducesVideoFile] public async Task<ActionResult> GetVideoStream( [FromRoute, Required] Guid itemId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -338,7 +338,7 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -359,8 +359,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, @@ -555,18 +555,18 @@ public class VideosController : BaseJellyfinApiController [ProducesVideoFile] public Task<ActionResult> GetVideoStreamByContainer( [FromRoute, Required] Guid itemId, - [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, + [FromRoute, Required][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, [FromQuery] string? deviceProfileId, [FromQuery] string? playSessionId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer, [FromQuery] int? segmentLength, [FromQuery] int? minSegments, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec, [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, @@ -576,7 +576,7 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, [FromQuery] string? profile, - [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, + [FromQuery][RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level, [FromQuery] float? framerate, [FromQuery] float? maxFramerate, [FromQuery] bool? copyTimestamps, @@ -597,8 +597,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? cpuCoreLimit, [FromQuery] string? liveStreamId, [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, - [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec, + [FromQuery][RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec, [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 9c24be82ea..36a8e197a2 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; @@ -10,6 +11,37 @@ namespace Jellyfin.Api.Extensions; public static class DtoExtensions { /// <summary> + /// Gets the BaseItemKind values associated with the specified CollectionType. + /// </summary> + /// <param name="collectionType">The collection type to map to BaseItemKind values.</param> + /// <returns>An array of BaseItemKind values that correspond to the collection type.</returns> + public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType) + { + switch (collectionType) + { + case CollectionType.movies: + return [BaseItemKind.Movie]; + case CollectionType.tvshows: + return [BaseItemKind.Series]; + case CollectionType.music: + return [BaseItemKind.MusicAlbum]; + case CollectionType.musicvideos: + return [BaseItemKind.MusicVideo]; + case CollectionType.books: + return [BaseItemKind.Book, BaseItemKind.AudioBook]; + case CollectionType.boxsets: + return [BaseItemKind.BoxSet]; + case CollectionType.homevideos: + case CollectionType.photos: + return [BaseItemKind.Video, BaseItemKind.Photo]; + case CollectionType.folders: + return []; + default: + return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series]; + } + } + + /// <summary> /// Add additional DtoOptions. /// </summary> /// <remarks> diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 4034a80887..d123dbc82e 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -62,12 +62,12 @@ public static class FileStreamResponseHelpers if (response.Headers.TryGetValues(HeaderNames.AcceptRanges, out var acceptRangesHeaders)) { // Prefer upstream server's Accept-Ranges header if available - acceptRangesValue = string.Join(", ", acceptRangesHeaders); - upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase); + acceptRangesValue = string.Join(", ", acceptRangesHeaders); + upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase); } else if (upstreamSupportsRange) // If we got 206 but no Accept-Ranges header, assume bytes { - acceptRangesValue = "bytes"; + acceptRangesValue = "bytes"; } // Set Accept-Ranges header for the client based on upstream support @@ -76,13 +76,13 @@ public static class FileStreamResponseHelpers // Set Content-Range header if upstream provided it (implies partial content) if (response.Content.Headers.ContentRange is not null) { - httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString(); + httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString(); } // Set Content-Length header. For partial content, this is the length of the partial segment. if (response.Content.Headers.ContentLength.HasValue) { - httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value; + httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value; } // Set Content-Type header diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 454d3f08e3..ef81235808 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -351,11 +351,20 @@ public class MediaInfoHelper /// </summary> /// <param name="result">Playback info response.</param> /// <param name="maxBitrate">Max bitrate.</param> - public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) + /// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param> + public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default) { var originalList = result.MediaSources.ToList(); - result.MediaSources = result.MediaSources.OrderBy(i => + // The queried item's source carries the user's resume state for that version, so it must stay the + // default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version. + var preferredId = preferredItemId.IsEmpty() + ? null + : preferredItemId.ToString("N", CultureInfo.InvariantCulture); + + result.MediaSources = result.MediaSources + .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => { // Nothing beats direct playing a file if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 5072f902da..d14c3a9343 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++) diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index bae2756303..6a6aac1327 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -144,6 +144,15 @@ public static class StreamingHelpers mediaSource = liveStreamInfo.Item1; state.DirectStreamProvider = liveStreamInfo.Item2; + // The requested live stream is no longer open. This commonly happens when a client keeps + // polling the HLS playlist (e.g. live.m3u8) after the stream was disposed because its + // consumer count dropped to zero. GetLiveStreamWithDirectStreamProvider returns a null + // MediaSource in that case, so return 404 instead of dereferencing it below. + if (mediaSource is null) + { + throw new ResourceNotFoundException($"The live stream with id {streamingRequest.LiveStreamId} could not be found or is no longer available."); + } + // Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate. if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null) { diff --git a/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs b/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs index c138324d2e..6e4ba91133 100644 --- a/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs +++ b/Jellyfin.Api/Models/SystemInfoDtos/LibraryStorageDto.cs @@ -10,7 +10,7 @@ namespace Jellyfin.Api.Models.SystemInfoDtos; /// </summary> public record LibraryStorageDto { - /// <summary> + /// <summary> /// Gets or sets the Library Id. /// </summary> public required Guid Id { get; set; } diff --git a/Jellyfin.Data/Enums/ActivityLogSortBy.cs b/Jellyfin.Data/Enums/ActivityLogSortBy.cs index d6d44e8c07..a24185e365 100644 --- a/Jellyfin.Data/Enums/ActivityLogSortBy.cs +++ b/Jellyfin.Data/Enums/ActivityLogSortBy.cs @@ -1,4 +1,4 @@ -namespace Jellyfin.Data.Enums; +namespace Jellyfin.Data.Enums; /// <summary> /// Activity log sorting options. diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index fe987b9d86..f21e94a0fd 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -56,11 +56,11 @@ public class ActivityManager : IActivityManager var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - // TODO switch to LeftJoin in .NET 10. - var entries = from a in dbContext.ActivityLogs - join u in dbContext.Users on a.UserId equals u.Id into ugj - from u in ugj.DefaultIfEmpty() - select new ExpandedActivityLog { ActivityLog = a, Username = u.Username }; + var entries = dbContext.ActivityLogs.LeftJoin( + dbContext.Users, + a => a.UserId, + u => u.Id, + (a, u) => new ExpandedActivityLog { ActivityLog = a, Username = u == null ? null : u.Username }); if (query.HasUserId is not null) { diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index bcf348f8c6..d0d52a23fb 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -213,8 +213,10 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.Devices.Remove(device); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + await dbContext.Devices + .Where(d => d.Id == device.Id) + .ExecuteDeleteAsync() + .ConfigureAwait(false); } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index 09d68e4451..a88904c727 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -75,9 +75,9 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session eventArgs.DeviceName), notificationType, user.Id) - { - ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture), - }) + { + ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture), + }) .ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index a6dc5458ee..a534fa5fa0 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Server.Implementations.StorageHelpers; using Jellyfin.Server.Implementations.SystemBackupService; using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SystemBackupService; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -33,6 +34,7 @@ public class BackupService : IBackupService private readonly IServerApplicationPaths _applicationPaths; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILibraryManager _libraryManager; private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General) { AllowTrailingCommas = true, @@ -50,13 +52,15 @@ public class BackupService : IBackupService /// <param name="applicationPaths">The application paths.</param> /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param> /// <param name="applicationLifetime">The SystemManager.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public BackupService( ILogger<BackupService> logger, IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost applicationHost, IServerApplicationPaths applicationPaths, IJellyfinDatabaseProvider jellyfinDatabaseProvider, - IHostApplicationLifetime applicationLifetime) + IHostApplicationLifetime applicationLifetime, + ILibraryManager libraryManager) { _logger = logger; _dbProvider = dbProvider; @@ -64,6 +68,7 @@ public class BackupService : IBackupService _applicationPaths = applicationPaths; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; _hostApplicationLifetime = applicationLifetime; + _libraryManager = libraryManager; } /// <inheritdoc/> @@ -263,6 +268,14 @@ public class BackupService : IBackupService /// <inheritdoc/> public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions) { + // Creating a backup runs a database optimization and reads the entire database under a transaction, both of + // which heavily contend with an active library scan and could capture an inconsistent database state. + if (_libraryManager.IsScanRunning) + { + _logger.LogWarning("Cannot create a backup while a library scan is running."); + throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished."); + } + var manifest = new BackupManifest() { DateCreated = DateTime.UtcNow, diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index 736388e9eb..c64e6ac068 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -26,7 +26,7 @@ namespace Jellyfin.Server.Implementations.Item; /// <summary> /// Handles mapping between BaseItemEntity (database) and BaseItemDto (domain) objects. /// </summary> -internal static class BaseItemMapper +public static class BaseItemMapper { /// <summary> /// This holds all the types in the running assemblies diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index e4fd3204e1..c5b5fbf6d8 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -170,12 +170,22 @@ public sealed partial class BaseItemRepository }; // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking - // the lowest Id per group. Keep as an IQueryable sub-select so paging is applied AFTER + // 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. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - var representativeIds = masterQuery - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => g.Min(e => e.Id)); + var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var representativeIds = isMusicArtist + ? masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g + .OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) + .ThenBy(e => e.Id) + .First().Id) + : masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Min(e => e.Id)); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index d6ddf8f5c8..a4de9feb05 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -62,18 +62,21 @@ public sealed partial class BaseItemRepository private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { - // Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking - // the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing + // Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the + // primary version (PrimaryVersionId is null) so detail pages and actions target it instead + // of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing // to a List would inline one bound parameter per id and hit SQLite's variable cap. var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) { - var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id)); + var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }) + .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id)); dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); } else if (enableGroupByPresentationUniqueKey) { - var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id)); + var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id)); dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id)); } else if (filter.GroupBySeriesPresentationUniqueKey) @@ -444,6 +447,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); @@ -452,7 +456,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)) + + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName); } // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 624b1b561c..f19df6259e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -513,13 +513,17 @@ public sealed partial class BaseItemRepository 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) { - var userId = filter.User!.Id; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var isResumable = filter.IsResumable.Value; - // Aggregate per series in a single GROUP BY pass, instead of three full scans. var seriesEpisodeStats = context.BaseItems .AsNoTracking() @@ -535,26 +539,49 @@ public sealed partial class BaseItemRepository // A series is resumable if it has an in-progress episode, // or if it has both played and unplayed episodes (partially watched). - var resumableSeriesIds = seriesEpisodeStats + resumableSeriesIds = seriesEpisodeStats .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) .Select(s => s.SeriesId); + } - // Non-series items: resumable if PlaybackPositionTicks > 0 - var resumableItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0) - .Select(ud => ud.ItemId); - - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable) - || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable)); + if (isResumable) + { + // Resume queries surface the version that was actually played, which may be an alternate. + // 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)); + + // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. + // 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. + baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !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 { - var resumableItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0) - .Select(ud => ud.ItemId); - var isResumable = filter.IsResumable.Value; - baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable); + // Not-resumable queries operate on primaries only. + 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)); } } @@ -586,8 +613,7 @@ public sealed partial class BaseItemRepository if (filter.AlbumIds.Length > 0) { - var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); - baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); + baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value)); } if (filter.ExcludeArtistIds.Length > 0) @@ -742,10 +768,13 @@ public sealed partial class BaseItemRepository } else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) { - // Exclude alternate versions and owned non-extra items from general queries. - // Alternate versions have PrimaryVersionId set (pointing to their primary). + // Exclude owned non-extra items from general queries. // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. - baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + // 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)); } if (filter.OwnerIds.Length > 0) @@ -953,24 +982,17 @@ public sealed partial class BaseItemRepository if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) { - var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f))); + baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds); } if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) { - // Allow setting a null or empty value to get all items that have the specified provider set. - var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray(); - if (includeAny.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId))); - } + baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId); + } - var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); - if (includeSelected.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); - } + if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) + { + baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds); } if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) @@ -1027,6 +1049,15 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); } + if (filter.LinkedChildAncestorIds.Length > 0) + { + // Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids. + var linkedChildAncestorIds = filter.LinkedChildAncestorIds; + baseQuery = baseQuery.Where(e => context.LinkedChildren.Any(lc => + lc.ParentId == e.Id + && lc.Child!.Parents!.Any(a => linkedChildAncestorIds.Contains(a.ParentItemId)))); + } + if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) { baseQuery = baseQuery @@ -1058,6 +1089,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); @@ -1068,6 +1100,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}\""))); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 94dedaeba8..57041276b7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository return false; } + // Resume queries surface the actually-played version (which may be an alternate sharing the + // primary's presentation key). The resumable filter already keeps one version per group, so + // presentation-key grouping must not collapse the surfaced version back onto the primary. + if (query.IsResumable == true) + { + return false; + } + if (query.GroupBySeriesPresentationUniqueKey) { return false; diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index ffa5cff1f2..b10f7c527e 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService descendantIds.Add(id); } + // 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 && descendantIds.Contains(e.OwnerId.Value)) + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value) .Select(e => e.Id) .ToArray(); @@ -557,9 +562,11 @@ public class ItemPersistenceService : IItemPersistenceService } } + // Deduplicate; local (file-based) relationships take priority over linked (user-merged) + // ones, matching the LinkedChildren migration. newLinkedChildren = newLinkedChildren .GroupBy(c => c.ChildId) - .Select(g => g.Last()) + .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First()) .ToList(); var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList(); diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 9e11b6be62..5e5ce320a5 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -91,14 +91,25 @@ public class LinkedChildrenService : ILinkedChildrenService } /// <inheritdoc/> - public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId) + public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null) { using var context = _dbProvider.CreateDbContext(); - return context.LinkedChildren - .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual) - .Select(lc => lc.ParentId) - .Distinct() - .ToList(); + + var query = context.LinkedChildren + .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual); + + if (parentType.HasValue) + { + var parentTypeName = _itemTypeLookup.BaseItemKindNames[parentType.Value]; + query = query.Join( + context.BaseItems + .Where(item => item.Type == parentTypeName), + lc => lc.ParentId, + item => item.Id, + (lc, _) => lc); + } + + return query.Select(lc => lc.ParentId).Distinct().ToList(); } /// <inheritdoc/> diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index d327b218a9..aac85d0131 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -34,7 +34,14 @@ public static class OrderMapper (ItemSortBy.AirTime, _) => e => e.SortName, (ItemSortBy.Runtime, _) => e => e.RunTimeTicks, (ItemSortBy.Random, _) => e => EF.Functions.Random(), - (ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate, + (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, diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 8f8741d00f..eb87b525fe 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -110,10 +110,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); var existingPersons = context.Peoples.Select(e => new - { - item = e, - SelectionKey = e.Name.ToLower() + "-" + e.PersonType - }) + { + item = e, + SelectionKey = e.Name.ToLower() + "-" + e.PersonType + }) .Where(p => personKeys.Contains(p.SelectionKey)) .Select(f => f.item) .ToArray(); @@ -165,6 +165,42 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I transaction.Commit(); } + /// <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)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + var rows = query + .OrderBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToList(); + + var result = new Dictionary<Guid, IReadOnlyList<string>>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) + { + var names = group + .Select(r => r.Name) + .Where(name => !string.IsNullOrEmpty(name)) + .Distinct() + .ToArray(); + + if (names.Length > 0) + { + result[group.Key] = names; + } + } + + return result; + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); @@ -239,7 +275,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value); + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value)); } if (!string.IsNullOrWhiteSpace(filter.NameContains)) diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index e3fe517c49..8657cb7dbb 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -302,7 +302,7 @@ namespace Jellyfin.Server.Implementations.Security } else if (!escaped && token == '=') { - key = authorizationHeader[start.. i].Trim().ToString(); + key = authorizationHeader[start..i].Trim().ToString(); start = i + 1; } } diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 0791e04e85..58b9f7f822 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay; /// <summary> /// ITrickplayManager implementation. /// </summary> -public class TrickplayManager : ITrickplayManager +public partial class TrickplayManager : ITrickplayManager { private readonly ILogger<TrickplayManager> _logger; private readonly IMediaEncoder _mediaEncoder; @@ -135,6 +136,147 @@ public class TrickplayManager : ITrickplayManager } } + private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken) + { + var options = _config.Configuration.TrickplayOptions; + var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false); + + // Remove DB rows whose on-disk folder no longer exists in either possible location. + // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration. + var orphanedWidths = new List<int>(); + foreach (var (width, info) in existing) + { + cancellationToken.ThrowIfCancellationRequested(); + var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false); + var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true); + if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir)) + { + orphanedWidths.Add(width); + } + } + + if (orphanedWidths.Count > 0) + { + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.TrickplayInfos + .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + foreach (var width in orphanedWidths) + { + _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path); + existing.Remove(width); + } + } + + var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + if (!Directory.Exists(trickplayDirectory)) + { + return; + } + + foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var match = TrickplaySubdirRegex().Match(subdir.Name); + if (!match.Success) + { + continue; + } + + var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + + if (existing.ContainsKey(width)) + { + continue; + } + + var tiles = subdir.GetFiles("*.jpg") + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToArray(); + if (tiles.Length == 0) + { + continue; + } + + // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real + // thumbnail count cannot be read from tile dimensions. Instead, bound the count from + // the tile count and per-tile capacity, then pick an interval consistent with the + // video runtime - snapping to the server's configured interval when it fits. + var thumbsPerTile = tileWidth * tileHeight; + var maxThumbs = tiles.Length * thumbsPerTile; + var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1; + + int interval; + int thumbnailCount; + if (video.RunTimeTicks is long ticks) + { + var runtimeMs = ticks / TimeSpan.TicksPerMillisecond; + var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs)); + var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs)); + + if (options.Interval >= minInterval && options.Interval <= maxInterval) + { + interval = options.Interval; + } + else + { + var midpoint = (minInterval + maxInterval) / 2.0; + var snapped = (long)Math.Round(midpoint / 1000d) * 1000L; + interval = (int)Math.Clamp(snapped, minInterval, maxInterval); + } + + thumbnailCount = Math.Clamp( + (int)Math.Round(runtimeMs / (double)interval), + minThumbs, + maxThumbs); + } + else + { + interval = Math.Max(1000, options.Interval); + thumbnailCount = maxThumbs; + } + + var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName); + var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight)); + + var info = new TrickplayInfo + { + ItemId = video.Id, + Width = width, + Interval = interval, + TileWidth = tileWidth, + TileHeight = tileHeight, + ThumbnailCount = thumbnailCount, + Height = thumbPxH, + Bandwidth = 0, + }; + + foreach (var tile in tiles) + { + var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m)); + info.Bandwidth = Math.Max(info.Bandwidth, bitrate); + } + + await SaveTrickplayInfo(info).ConfigureAwait(false); + _logger.LogInformation( + "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}", + width, + tileWidth, + tileHeight, + thumbnailCount, + interval, + video.Path); + } + } + /// <inheritdoc /> public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken) { @@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager return; } + var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; + + // Catalog any existing trickplay folders on disk before any prune/generate. This picks up + // user-placed files even when their (width, tile dims) don't match the server's configured values. + if (!replace) + { + await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false); + } + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + + // When extraction is disabled and files live next to media, treat them as user-managed: + // discovery above already catalogued whatever is on disk, leave it alone. + if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia) + { + return; + } + if (!libraryOptions.EnableTrickplayImageExtraction || replace) { // Prune existing data @@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager return Path.Combine(path, subdirectory); } + [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")] + private static partial Regex TrickplaySubdirRegex(); + + private static bool HasTrickplayTiles(string directory) + { + if (!Directory.Exists(directory)) + { + return false; + } + + return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any(); + } + private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 8c0cbbd448..41648268a9 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,4 +1,3 @@ -#pragma warning disable CA1307 #pragma warning disable RS0030 // Do not use banned APIs using System; @@ -52,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly AsyncKeyedLocker<Guid> _userLock = new(); + private readonly LockHelper _userLock = new(); /// <summary> /// Initializes a new instance of the <see cref="UserManager"/> class. @@ -161,12 +160,8 @@ namespace Jellyfin.Server.Implementations.Users using var dbContext = _dbProvider.CreateDbContext(); #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings return UserQuery(dbContext) - .FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper()); -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture + .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } @@ -175,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users { ThrowIfInvalidUsername(newName); - if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)) + if (oldName.Equals(newName, StringComparison.Ordinal)) { throw new ArgumentException("The new and old names must be different."); } @@ -187,10 +182,8 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings if (await dbContext.Users - .AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId) + .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( @@ -198,8 +191,6 @@ namespace Jellyfin.Server.Implementations.Users "A user with the name '{0}' already exists.", newName)); } -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons user = await UserQuery(dbContext) @@ -208,6 +199,7 @@ namespace Jellyfin.Server.Implementations.Users .ConfigureAwait(false) ?? throw new ResourceNotFoundException(nameof(userId)); user.Username = newName; + user.NormalizedUsername = newName.ToUpperInvariant(); await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); } } @@ -222,7 +214,58 @@ namespace Jellyfin.Server.Implementations.Users { using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - await UpdateUserInternalAsync(user).ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false) + ?? 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)); + } + + dbUser.AccessSchedules.Clear(); + foreach (var accessSchedule in user.AccessSchedules) + { + dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id)); + } + + if (user.ProfileImage is null) + { + if (dbUser.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + } + } + else if (dbUser.ProfileImage is null) + { + dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path) + { + LastModified = user.ProfileImage.LastModified + }; + } + else + { + dbUser.ProfileImage.Path = user.ProfileImage.Path; + dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified; + } + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } } @@ -257,10 +300,8 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings if (await dbContext.Users - .AnyAsync(u => u.Username.ToUpper() == name.ToUpper()) + .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( @@ -268,8 +309,6 @@ namespace Jellyfin.Server.Implementations.Users "A user with the name '{0}' already exists.", name)); } -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); @@ -465,12 +504,14 @@ namespace Jellyfin.Server.Implementations.Users var user = GetUserByName(username); using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) { + using var dbContext = _dbProvider.CreateDbContext(); + // Reload the user now that we hold the lock so the RowVersion is current. // GetUserByName uses AsNoTracking and the snapshot may be stale if another // write (e.g. a concurrent login) incremented RowVersion after our initial load. if (user is not null) { - user = GetUserById(user.Id) ?? user; + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; } var authResult = await AuthenticateLocalUser(username, password, user) @@ -478,6 +519,13 @@ namespace Jellyfin.Server.Implementations.Users var authenticationProvider = authResult.AuthenticationProvider; success = authResult.Success; + if (success && user is not null) + { + // refresh the user if the auth provider might have updated it in the auth method. + // this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead. + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false); + } + if (user is null) { string updatedUsername = authResult.Username; @@ -491,11 +539,16 @@ namespace Jellyfin.Server.Implementations.Users // Search the database for the user again // the authentication provider might have created it - user = GetUserByName(username); +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) { await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } } } @@ -506,8 +559,10 @@ namespace Jellyfin.Server.Implementations.Users if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) { - user.AuthenticationProviderId = providerId; - await UpdateUserInternalAsync(user).ConfigureAwait(false); + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId)) + .ConfigureAwait(false); } } @@ -554,16 +609,49 @@ namespace Jellyfin.Server.Implementations.Users { if (isUserSession) { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + var date = DateTime.UtcNow; + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e + .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; } - user.InvalidLoginAttemptCount = 0; - await UpdateUserInternalAsync(user).ConfigureAwait(false); + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0)) + .ConfigureAwait(false); _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); } else { - await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); + user.InvalidLoginAttemptCount++; + int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; + if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + { + user.SetPermission(PermissionKind.IsDisabled, true); + dbContext.Update(user); + await dbContext.SaveChangesAsync() + .ConfigureAwait(false); + await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); + _logger.LogWarning( + "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", + user.Username, + user.InvalidLoginAttemptCount); + } + + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1)) + .ConfigureAwait(false); + _logger.LogInformation( "Authentication request for {UserName} has been denied (IP: {IP}).", user.Username, @@ -801,8 +889,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; @@ -938,32 +1038,6 @@ namespace Jellyfin.Server.Implementations.Users } } - private async Task IncrementInvalidLoginAttemptCount(User user) - { - user.InvalidLoginAttemptCount++; - int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; - if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) - { - user.SetPermission(PermissionKind.IsDisabled, true); - await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); - _logger.LogWarning( - "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", - user.Username, - user.InvalidLoginAttemptCount); - } - - await UpdateUserInternalAsync(user).ConfigureAwait(false); - } - - private async Task UpdateUserInternalAsync(User user) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); - } - } - private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Attach(user); @@ -989,5 +1063,70 @@ namespace Jellyfin.Server.Implementations.Users _userLock.Dispose(); } } + + internal sealed class LockHelper : IDisposable + { + private readonly AsyncKeyedLocker<Guid> _userLock = new(); + + private bool _disposed; + + public static AsyncLocal<int> IsNestedLock { get; set; } = new(); + + public bool ShouldLock() + { + return IsNestedLock.Value == 0; + } + + public ValueTask<IDisposable> LockAsync(Guid key) + { + ThrowIfDisposed(); + var isNested = LockHelper.IsNestedLock.Value != 0; + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1; + if (isNested) + { + return new ValueTask<IDisposable>(new LockHandle { Parent = null }); + } + + return AcquireLockAsync(key); + } + + private async ValueTask<IDisposable> AcquireLockAsync(Guid key) + { + var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false); + return new LockHandle { Parent = lockHandle }; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _userLock.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed class LockHandle : IDisposable + { + public required IDisposable? Parent { get; init; } + + public void Dispose() + { + Parent?.Dispose(); + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1; + + if (LockHelper.IsNestedLock.Value < 0) + { + throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible."); + } + } + } + } } } diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 2aadedfa61..6db0edb237 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API"); c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css"); c.RoutePrefix = "api-docs/swagger"; + c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }"""); }) .UseReDoc(c => { diff --git a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs index fdc49a9840..c9fd031ef9 100644 --- a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs +++ b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs @@ -68,7 +68,7 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider try { - openApiDocument = _swaggerGenerator.GetSwagger(documentName); + openApiDocument = _swaggerGenerator.GetSwagger(documentName); } catch (Exception ex) { diff --git a/Jellyfin.Server/GlobalSuppressions.cs b/Jellyfin.Server/GlobalSuppressions.cs new file mode 100644 index 0000000000..676747e29f --- /dev/null +++ b/Jellyfin.Server/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Migration files should follow the EFCore standard in regards to naming.", Scope = "namespaceanddescendants", Target = "~N:Jellyfin.Server.Migrations.Routines")] diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index d664b718bc..a10be76e05 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -193,84 +193,92 @@ internal class JellyfinMigrationService { var historyRepository = dbContext.GetService<IHistoryRepository>(); var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>(); - var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); - var pendingCodeMigrations = migrationStage - .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) - .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) - .ToArray(); - - (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; - if (stage is JellyfinMigrationStageTypes.CoreInitialisation) - { - pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) - .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) - .ToArray(); - } - - (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; - logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); - var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + (string Key, IInternalMigration Migration)[] migrations = []; + + do + { // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply. + var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + var pendingCodeMigrations = migrationStage + .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) + .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) + .ToArray(); - foreach (var item in migrations) - { - var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); - try + (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; + if (stage is JellyfinMigrationStageTypes.CoreInitialisation) { - migrationLogger.LogInformation("Perform migration {Name}", item.Key); - await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); - migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); + pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) + .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) + .ToArray(); } - 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) + (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(); + + var migrationIndex = 0; + foreach (var item in migrations) + { + // 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 { - if (_backupKey.LibraryDb is not null) - { - 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); - } - } + 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.JellyfinDb is not null) + if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) { - migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); - try + if (_backupKey.LibraryDb is not null) { - await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); + 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); + } } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); - } - } - if (_backupKey.FullBackup is not null) - { - migrationLogger.LogInformation("Attempt to rollback from backup."); - try + if (_backupKey.JellyfinDb is not null) { - await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); + 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); + } } - catch (Exception inner) + + if (_backupKey.FullBackup is not null) { - migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); + 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; + throw; + } } - } + } while (migrations.Length != 0); } } diff --git a/Jellyfin.Server/Migrations/MigrationOptions.cs b/Jellyfin.Server/Migrations/MigrationOptions.cs index c9710f1fd1..cd1b74a613 100644 --- a/Jellyfin.Server/Migrations/MigrationOptions.cs +++ b/Jellyfin.Server/Migrations/MigrationOptions.cs @@ -16,7 +16,7 @@ namespace Jellyfin.Server.Migrations Applied = new List<(Guid Id, string Name)>(); } -// .Net xml serializer can't handle interfaces + // .Net xml serializer can't handle interfaces #pragma warning disable CA1002 // Do not expose generic lists /// <summary> /// Gets the list of applied migration routine names. diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs index acf2835fe0..acf2835fe0 100644 --- a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs index 1326a6dc8d..1326a6dc8d 100644 --- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs index 8c8563190d..8c8563190d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs index c9e66d0cfe..c9e66d0cfe 100644 --- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs index 8c8398a161..8c8398a161 100644 --- a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs index 8c3361ee16..8c3361ee16 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs index ebf4a2780e..ebf4a2780e 100644 --- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs index ffd06fea0d..ffd06fea0d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs index b626c473e3..b626c473e3 100644 --- a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs index 0de775e03a..cbdc6efb44 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; @@ -79,9 +80,9 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var row in authenticatedDevices) { var dateCreatedStr = row.GetString(9); - _ = DateTime.TryParse(dateCreatedStr, out var dateCreated); + _ = DateTime.TryParse(dateCreatedStr, CultureInfo.InvariantCulture, out var dateCreated); var dateLastActivityStr = row.GetString(10); - _ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity); + _ = DateTime.TryParse(dateLastActivityStr, CultureInfo.InvariantCulture, out var dateLastActivity); if (row.IsDBNull(6)) { diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs index 56614ece3c..56614ece3c 100644 --- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs index 00d152b4b8..00d152b4b8 100644 --- a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs diff --git a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs index f58cf27413..f58cf27413 100644 --- a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs index d102e24b91..d102e24b91 100644 --- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs index 1545ebdc8e..1545ebdc8e 100644 --- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs index d4cc9bbeed..d4cc9bbeed 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs index 3e4205547a..3e4205547a 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs index cfc1628782..cfc1628782 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs index 79a8f9577c..79a8f9577c 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs diff --git a/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs b/Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs index b23a7dbc42..b23a7dbc42 100644 --- a/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs index aa55309264..aa55309264 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs index 8a0a1741f1..8a0a1741f1 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs index a5b11b11d0..a5b11b11d0 100644 --- a/Jellyfin.Server/Migrations/Routines/FixDates.cs +++ b/Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs diff --git a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs index 502763ac09..502763ac09 100644 --- a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs +++ b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs diff --git a/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs b/Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs index d5c5f3d929..d5c5f3d929 100644 --- a/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs +++ b/Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index 74f03f5107..c433c1d043 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -223,6 +223,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList(); + // Drop linked (user-merged) entries that point at items the parent owns (local + // file-based alternates or extras). These stem from legacy data that merged an + // owned item onto its own primary and would wrongly mark server-merged groups + // as user-merged (splittable). + var linkedChildIds = toInsert + .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion) + .Select(lc => lc.ChildId) + .Distinct() + .ToList(); + + if (linkedChildIds.Count > 0) + { + var ownerIdByChildId = context.BaseItems + .WhereOneOrMany(linkedChildIds, b => b.Id) + .Where(b => b.OwnerId.HasValue) + .Select(b => new { b.Id, b.OwnerId }) + .ToDictionary(b => b.Id, b => b.OwnerId!.Value); + + var removedCount = toInsert.RemoveAll(lc => + lc.ChildType == LinkedChildType.LinkedAlternateVersion + && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId) + && ownerId.Equals(lc.ParentId)); + + if (removedCount > 0) + { + _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount); + } + } + context.LinkedChildren.AddRange(toInsert); context.SaveChanges(); diff --git a/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs index 14abaa7317..e8eeb2da20 100644 --- a/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.Migrations.Stages; using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Controller.Channels; @@ -23,7 +24,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// Removes orphaned extras (items with OwnerId pointing to non-existent items). /// Must run before EF migrations that add FK constraints on OwnerId. /// </summary> -[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.CoreInitialisation)] +[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.AppInitialisation)] [JellyfinMigrationBackup(JellyfinDb = true)] public class CleanupOrphanedExtras : IAsyncMigrationRoutine { @@ -37,39 +38,14 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine /// <param name="logger">The startup logger.</param> /// <param name="dbContextFactory">The database context factory.</param> /// <param name="libraryManager">The library manager.</param> - /// <param name="itemRepository">The item repository.</param> - /// <param name="itemCountService">The item count service.</param> - /// <param name="channelManager">The channel manager.</param> - /// <param name="recordingsManager">The recordings manager.</param> - /// <param name="mediaSourceManager">The media source manager.</param> - /// <param name="mediaSegmentManager">The media segments manager.</param> - /// <param name="configurationManager">The configuration manager.</param> - /// <param name="fileSystem">The file system.</param> public CleanupOrphanedExtras( IStartupLogger<CleanupOrphanedExtras> logger, IDbContextFactory<JellyfinDbContext> dbContextFactory, - ILibraryManager libraryManager, - IItemRepository itemRepository, - IItemCountService itemCountService, - IChannelManager channelManager, - IRecordingsManager recordingsManager, - IMediaSourceManager mediaSourceManager, - IMediaSegmentManager mediaSegmentManager, - IServerConfigurationManager configurationManager, - IFileSystem fileSystem) + ILibraryManager libraryManager) { _logger = logger; _dbContextFactory = dbContextFactory; _libraryManager = libraryManager; - BaseItem.LibraryManager ??= libraryManager; - BaseItem.ItemRepository ??= itemRepository; - BaseItem.ItemCountService ??= itemCountService; - BaseItem.ChannelManager ??= channelManager; - BaseItem.MediaSourceManager ??= mediaSourceManager; - BaseItem.MediaSegmentManager ??= mediaSegmentManager; - BaseItem.ConfigurationManager ??= configurationManager; - BaseItem.FileSystem ??= fileSystem; - Video.RecordingsManager ??= recordingsManager; } /// <inheritdoc/> @@ -78,12 +54,19 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { + var placeholderOwner = Guid.Parse("00000000-0000-0000-0000-000000000001"); +#pragma warning disable RS0030 // Do not use banned APIs var orphanedItemIds = await context.BaseItems - .Where(b => b.OwnerId.HasValue && !b.OwnerId.Value.Equals(Guid.Empty)) - .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) - .Select(b => b.Id) + .Where(b => b.OwnerId.HasValue && b.OwnerId == placeholderOwner) + .Select(b => new + { + b.Id, + b.Path, + b.Type + }) .ToListAsync(cancellationToken) .ConfigureAwait(false); +#pragma warning restore RS0030 // Do not use banned APIs if (orphanedItemIds.Count == 0) { @@ -93,20 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine _logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count); - // Batch-resolve items for metadata path cleanup, then delete all at once - var itemsToDelete = new List<BaseItem>(); - foreach (var itemId in orphanedItemIds) + // Resolve items for metadata path cleanup, then delete in batches so we never issue one + // massive delete transaction and progress stays visible on large libraries. + _logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize) { - var item = _libraryManager.GetItemById(itemId); - if (item is not null) - { - itemsToDelete.Add(item); - } - } + cancellationToken.ThrowIfCancellationRequested(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset)); + var itemsToDelete = batch + .Select(itemId => BaseItemMapper.DeserializeBaseItem( + new Database.Implementations.Entities.BaseItemEntity() + { + Id = itemId.Id, + Path = itemId.Path, + Type = itemId.Type + }, + _logger, + null, + true)!) + .ToList(); + + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + + deletedSoFar += batch.Count; + _logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count); + } - _logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count); + _logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count); } } } diff --git a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs index 0baf261a2e..e34182fd5d 100644 --- a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs +++ b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs @@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine if (allIdsToDelete.Count > 0) { - // Batch-resolve items for metadata path cleanup, then delete all at once - var itemsToDelete = allIdsToDelete - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - - // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager - var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); - var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); - if (unresolvedIds.Count > 0) + _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count); + + // Delete in batches so progress is visible (item resolution and deletion can take a + // long time on large libraries) and so we never issue one massive delete transaction. + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize) { - _persistenceService.DeleteItem(unresolvedIds); + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset)); + + // Resolve items for metadata path cleanup, then delete this batch + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count); } } diff --git a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs b/Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs index 2b1f549940..2b1f549940 100644 --- a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs +++ b/Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs index ed92c34aa3..ed92c34aa3 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs diff --git a/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs index f598848465..bff6ebbfb0 100644 --- a/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs +++ b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs @@ -182,23 +182,35 @@ public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the // %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind. // Fall back to the persistence service for any items the LibraryManager can't resolve. - var itemsToDelete = idsToDelete - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - if (itemsToDelete.Count > 0) + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) { - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - } + cancellationToken.ThrowIfCancellationRequested(); - var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); - var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); - if (unresolvedIds.Count > 0) - { - _persistenceService.DeleteItem(unresolvedIds); - } + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); + + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } - _logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count); + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } } } } diff --git a/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs index d092555139..f28c804d26 100644 --- a/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs +++ b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs @@ -184,23 +184,35 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind. - var itemsToDelete = idsToDelete - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - if (itemsToDelete.Count > 0) + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) { - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - } + cancellationToken.ThrowIfCancellationRequested(); - var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); - var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); - if (unresolvedIds.Count > 0) - { - _persistenceService.DeleteItem(unresolvedIds); - } + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); + + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } - _logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count); + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } } private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken) @@ -284,10 +296,16 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine return; } - await context.Peoples - .Where(p => idsToDelete.Contains(p.Id)) - .ExecuteDeleteAsync(cancellationToken) - .ConfigureAwait(false); + var idx = 0; + foreach (var item in idsToDelete.Chunk(200)) + { + idx++; // humans count at one + _logger.LogInformation("Remove batch {BatchNo}/{MaxBatches} duplicate Peoples.", idx, idsToDelete.Count / 200); + await context.Peoples + .Where(p => item.Contains(p.Id)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } _logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count); } diff --git a/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs new file mode 100644 index 0000000000..8100d4759e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Part 2 Migration for NormalisedUsername. +/// </summary> +[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] +#pragma warning disable SA1649 // File name should match first type name +public class UpdateNormalizedUsername : IAsyncMigrationRoutine +#pragma warning restore SA1649 // File name should match first type name +{ + private readonly IDbContextFactory<JellyfinDbContext> _contextFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class. + /// </summary> + /// <param name="contextFactory">Db Context factory.</param> + public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory) + { + _contextFactory = contextFactory; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var user in users) + { + user.NormalizedUsername = user.Username.ToUpperInvariant(); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs new file mode 100644 index 0000000000..d8dfe181ca --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that +/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by +/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem. +/// </summary> +[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanupOrphanedExternalData : IAsyncMigrationRoutine +{ + private const int ProgressLogStep = 500; + + private readonly IStartupLogger<CleanupOrphanedExternalData> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly IApplicationPaths _appPaths; + private readonly IServerApplicationPaths _serverPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="serverPaths">The server application paths.</param> + public CleanupOrphanedExternalData( + IStartupLogger<CleanupOrphanedExternalData> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + IApplicationPaths appPaths, + IServerApplicationPaths serverPaths) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _appPaths = appPaths; + _serverPaths = serverPaths; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false); + + CleanupGuidIndexedRoot( + "attachment", + Path.Combine(_appPaths.DataPath, "attachments"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "subtitle", + Path.Combine(_appPaths.DataPath, "subtitles"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "trickplay", + _appPaths.TrickplayPath, + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "chapter image", + Path.Combine(_serverPaths.InternalMetadataPath, "library"), + knownIds, + deleteSubPath: "chapters", + cancellationToken); + } + + private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var ids = await context.BaseItems + .AsNoTracking() + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return [.. ids]; + } + } + + private void CleanupGuidIndexedRoot( + string label, + string root, + HashSet<Guid> knownIds, + string? deleteSubPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root); + return; + } + + _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root); + + var scanned = 0; + var removed = 0; + foreach (var prefixDir in Directory.EnumerateDirectories(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var prefixName = Path.GetFileName(prefixDir); + if (prefixName.Length != 2) + { + continue; + } + + foreach (var guidDir in Directory.EnumerateDirectories(prefixDir)) + { + cancellationToken.ThrowIfCancellationRequested(); + + scanned++; + if (scanned % ProgressLogStep == 0) + { + _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed); + } + + var leafName = Path.GetFileName(guidDir); + if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id)) + { + continue; + } + + if (knownIds.Contains(id)) + { + continue; + } + + var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath); + if (deleteSubPath is not null && !Directory.Exists(target)) + { + continue; + } + + if (TryDelete(target)) + { + removed++; + } + } + } + + _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed); + } + + private bool TryDelete(string dir) + { + try + { + Directory.Delete(dir, recursive: true); + return true; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir); + } + + return false; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs new file mode 100644 index 0000000000..4b8ced90ac --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to disable legacy authorization in the system config. +/// </summary> +[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] +public class DisableLegacyAuthorization : IAsyncMigrationRoutine +{ + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public Task PerformAsync(CancellationToken cancellationToken) + { + _serverConfigurationManager.Configuration.EnableLegacyAuthorization = false; + _serverConfigurationManager.SaveConfiguration(); + + return Task.CompletedTask; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs index eca50ac100..7ade727d9b 100644 --- a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs +++ b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs @@ -12,22 +12,22 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Routines; /// <summary> -/// Migration to refresh CleanName values for all library items. +/// Migration to refresh CleanName values for all library items and CleanValue values for all item values. /// </summary> -[JellyfinMigration("2025-10-08T12:00:00", nameof(RefreshCleanNames))] +[JellyfinMigration("2026-06-10T12:00:00", nameof(RefreshCleanNamesAndValues))] [JellyfinMigrationBackup(JellyfinDb = true)] -public class RefreshCleanNames : IAsyncMigrationRoutine +public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine { - private readonly IStartupLogger<RefreshCleanNames> _logger; + private readonly IStartupLogger<RefreshCleanNamesAndValues> _logger; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; /// <summary> - /// Initializes a new instance of the <see cref="RefreshCleanNames"/> class. + /// Initializes a new instance of the <see cref="RefreshCleanNamesAndValues"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> - public RefreshCleanNames( - IStartupLogger<RefreshCleanNames> logger, + public RefreshCleanNamesAndValues( + IStartupLogger<RefreshCleanNamesAndValues> logger, IDbContextFactory<JellyfinDbContext> dbProvider) { _logger = logger; @@ -37,6 +37,12 @@ public class RefreshCleanNames : IAsyncMigrationRoutine /// <inheritdoc /> public async Task PerformAsync(CancellationToken cancellationToken) { + await RefreshCleanNamesAsync(cancellationToken).ConfigureAwait(false); + await RefreshCleanValuesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task RefreshCleanNamesAsync(CancellationToken cancellationToken) + { const int Limit = 10000; int itemCount = 0; @@ -99,4 +105,69 @@ public class RefreshCleanNames : IAsyncMigrationRoutine records, sw.Elapsed); } + + private async Task RefreshCleanValuesAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.ItemValues.Count(b => !string.IsNullOrEmpty(b.Value)); + _logger.LogInformation("Refreshing CleanValue for {Count} item values", records); + + var processedInPartition = 0; + + await foreach (var item in context.ItemValues + .Where(b => !string.IsNullOrEmpty(b.Value)) + .OrderBy(e => e.ItemValueId) + .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 newCleanValue = string.IsNullOrWhiteSpace(item.Value) ? string.Empty : item.Value.GetCleanValue(); + if (!string.Equals(newCleanValue, item.CleanValue, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating CleanValue for item value {Id}: '{OldValue}' -> '{NewValue}'", + item.ItemValueId, + item.CleanValue, + newCleanValue); + item.CleanValue = newCleanValue; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update CleanValue for item value {Id} ({Value})", item.ItemValueId, item.Value); + } + + 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 CleanValue for {UpdatedCount} out of {TotalCount} item values in {Time}", + itemCount, + records, + sw.Elapsed); + } } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index af0d424aad..12f92efb35 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -133,10 +133,12 @@ namespace Jellyfin.Server } } + SetupServer.ReportActivity(StartupActivity.CheckingStorage); StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check")); StartupHelpers.PerformStaticInitialization(); + SetupServer.ReportActivity(StartupActivity.Initializing); await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false); do @@ -195,6 +197,7 @@ namespace Jellyfin.Server if (!string.IsNullOrWhiteSpace(_restoreFromBackup)) { + SetupServer.ReportActivity(StartupActivity.RestoringBackup); await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false); _restoreFromBackup = null; _restartOnShutdown = true; @@ -202,9 +205,13 @@ namespace Jellyfin.Server } var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider); + SetupServer.ReportActivity(StartupActivity.PreparingMigrations); 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); + SetupServer.ReportActivity(StartupActivity.InitializingServices); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); _appHost = appHost; diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 05975929db..598de5aa5f 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; -using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; -using Morestachio; -using Morestachio.Framework.IO.SingleStream; -using Morestachio.Rendering; using Serilog; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; - private IRenderer? _startupUiRenderer; + private static volatile string _currentActivity = StartupActivity.Starting; + private StartupUiRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; private bool _isUnhealthy; @@ -77,6 +74,12 @@ public sealed class SetupServer : IDisposable internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new(); /// <summary> + /// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the + /// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details. + /// </summary> + internal static string CurrentActivity => _currentActivity; + + /// <summary> /// Gets a value indicating whether Startup server is currently running. /// </summary> public bool IsAlive { get; internal set; } @@ -87,57 +90,9 @@ public sealed class SetupServer : IDisposable /// <returns>A Task.</returns> public async Task RunAsync() { - var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); - _startupUiRenderer = (await ParserOptionsBuilder.New() - .WithTemplate(fileTemplate) - .WithFormatter( - (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => - { - if (children.Any()) - { - var maxLevel = logEntry.LogLevel; - var stack = new Stack<StartupLogTopic>(children); - - while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. - { - maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; - foreach (var child in logEntry.Children) - { - stack.Push(child); - } - } - - return maxLevel; - } - - return logEntry.LogLevel; - }, - "FormatLogLevel") - .WithFormatter( - (LogLevel logLevel) => - { - switch (logLevel) - { - case LogLevel.Trace: - case LogLevel.Debug: - case LogLevel.None: - return "success"; - case LogLevel.Information: - return "info"; - case LogLevel.Warning: - return "warn"; - case LogLevel.Error: - return "danger"; - case LogLevel.Critical: - return "danger-strong"; - } - - return string.Empty; - }, - "ToString") - .BuildAndParseAsync() - .ConfigureAwait(false)) - .CreateCompiledRenderer(); + ReportActivity(StartupActivity.Starting); + _startupUiRenderer = await StartupUiRenderer.CreateAsync( + Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); ThrowIfDisposed(); var retryAfterValue = TimeSpan.FromSeconds(5); @@ -237,6 +192,7 @@ public sealed class SetupServer : IDisposable }); }); + var version = typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version!; app.Run(async (context) => { context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; @@ -249,13 +205,14 @@ public sealed class SetupServer : IDisposable new Dictionary<string, object>() { { "isInReportingMode", _isUnhealthy }, + { "currentActivity", CurrentActivity }, { "retryValue", retryAfterValue }, - { "version", typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version! }, + { "version", version }, { "logs", startupLogEntries }, { "networkManagerReady", networkManager is not null }, { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } }, - new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) + context.Response.BodyWriter.AsStream()) .ConfigureAwait(false); }); }); @@ -301,6 +258,16 @@ public sealed class SetupServer : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); } + /// <summary> + /// Reports the current startup activity shown to all clients in the startup UI header. + /// Only pass generic, non-identifying text from <see cref="StartupActivity"/>. + /// </summary> + /// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param> + internal static void ReportActivity(string activity) + { + _currentActivity = activity; + } + internal void SoftStop() { _isUnhealthy = true; diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs new file mode 100644 index 0000000000..888cc617d4 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup. +/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every +/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.). +/// </summary> +public static class StartupActivity +{ + /// <summary>The default state before any work has been reported.</summary> + public const string Starting = "Starting up"; + + /// <summary>Validating that the configured storage locations are usable.</summary> + public const string CheckingStorage = "Checking storage"; + + /// <summary>Bringing up the migration subsystem and running early startup checks.</summary> + public const string Initializing = "Initializing server"; + + /// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary> + public const string PreparingMigrations = "Preparing migrations"; + + /// <summary>Restoring from a backup.</summary> + public const string RestoringBackup = "Restoring backup"; + + /// <summary>Bringing up core services and plugins.</summary> + public const string InitializingServices = "Initializing services"; + + /// <summary>Running the final startup tasks.</summary> + public const string FinishingStartup = "Finishing startup"; + + /// <summary> + /// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed. + /// </summary> + /// <param name="current">The 1-based index of the migration currently running.</param> + /// <param name="total">The total number of migrations in this batch.</param> + /// <returns>A generic progress description.</returns> + public static string Migration(int current, int total) + => string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total); +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs new file mode 100644 index 0000000000..db07b9d8c1 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Morestachio; +using Morestachio.Framework.IO.SingleStream; +using Morestachio.Rendering; + +namespace Jellyfin.Server.ServerSetupApp; + +/// <summary> +/// Compiles and renders the startup UI Morestachio template. +/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both +/// exercise the exact same template and formatters. +/// </summary> +public sealed class StartupUiRenderer +{ + private readonly IRenderer _renderer; + + private StartupUiRenderer(IRenderer renderer) + { + _renderer = renderer; + } + + /// <summary> + /// Compiles the startup UI template located at <paramref name="templatePath"/>. + /// </summary> + /// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param> + /// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns> + public static async Task<StartupUiRenderer> CreateAsync(string templatePath) + { + var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false); + var renderer = (await ParserOptionsBuilder.New() + .WithTemplate(fileTemplate) + .WithFormatter( + (Version version, int arg) => + { + // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. + return version.ToString(arg); + }, + "ToString") + .WithFormatter( + (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack<StartupLogTopic>(children); + + while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. + { + maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; + foreach (var child in logEntry.Children) + { + stack.Push(child); + } + } + + return maxLevel; + } + + return logEntry.LogLevel; + }, + "FormatLogLevel") + .WithFormatter( + (LogLevel logLevel) => + { + switch (logLevel) + { + case LogLevel.Trace: + case LogLevel.Debug: + case LogLevel.None: + return "success"; + case LogLevel.Information: + return "info"; + case LogLevel.Warning: + return "warn"; + case LogLevel.Error: + return "danger"; + case LogLevel.Critical: + return "danger-strong"; + } + + return string.Empty; + }, + "ToString") + .BuildAndParseAsync() + .ConfigureAwait(false)) + .CreateCompiledRenderer(); + + return new StartupUiRenderer(renderer); + } + + /// <summary> + /// Renders the template with the provided model into the target stream. + /// </summary> + /// <param name="model">The values made available to the template.</param> + /// <param name="output">The stream the rendered HTML is written to.</param> + /// <returns>A Task.</returns> + public Task RenderAsync(IDictionary<string, object> model, Stream output) + { + return _renderer.RenderAsync( + model, + new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 890a77619d..9c12762c31 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -1,189 +1,469 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="color-scheme" content="dark" /> + <meta name="theme-color" content="#202020" /> <title> {{#IF isInReportingMode}} ❌ {{/IF}} - Jellyfin Startup + Jellyfin </title> <style> + /* Noto Sans (latin), matching the Jellyfin web client font. Embedded so the page renders + identically before the web client is available. */ + @font-face { + font-family: "Noto Sans"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(data:font/woff2;base64,d09GMgABAAAAADNAABAAAAAAbeQAADLfAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoFEG6hUHIVKBmA/U1RBVF4AgiwRCAqBiTTsBwuENAABNgIkA4hkBCAFhHgHiTYMBxvtW0VGbVbtFQ+iCDYORgLhOYpKTvHs/1sCJ0OEzoeqzl1USdogmrFEkLWP1hL2EmhtdaOob8/YxPr65zzcRv33VG8ypk8RUUUMCwNBHL+t4HlCwS/x3YpUQNm6tkbM8ghNTtHK8/3L9d8+dfvL/YZUBiFSzGz+hFAsrpyplRFdP0/bfO9fCOdBS1lYiFGNiYVro7axiGxrVeU/5rQkW4mT97/jtINIshXoNu1Q2skyWFf7OsR8Wx6iOWtKg2ghECUhIoYmu0k2RsSIIaJtqRpVKHV96vJ1uHOoKL3rufSkV6qXPbXq+XskFyQkWJTFoAwC4xDWYfcdzOsmh/ohDoxv9PExIsdH2dgHdnMX/Om98r3WM63X2fpETJJztmBKfP0AWOqsJA0+/Q3XqumSjO9CRfgIQxmPQjM+ivigDEb8IHvViz3sgUup14AABAKHn/eF0q7lXeizPj0AD/b9Wu/bPdsh6oSFQrUVYcI6wseYN/fO1quznQnw5IZoAswSUEbGRqEick9GoUo+6/9/Ost2ZrXzzj52iPdyxwkXDb5eAaywSlFJ/48sjcZakM9L3uQt4RHYRz7vbbwQIqysEC6EiDvAoiSqirRt0gG3XeqGTEc4rv1RVd6ADWkWnmDFbtNvI9/+05TmsfkFExZZPCMyGM8bBjGlXfdfH0ZzXYD6kcvkuABH9dwnCSIWWA48QIxhODLN1yLQa++BwmFh9YGJgWgYP4SIMxfIXRyUqg3UMRENbDs53FzOt5f7nfLnEEX4KcQHH2GkZaCiCocifR7YBtgImM4JkUCJqJinS4VYz3lJWmj3fcBihRZjj/p9ekHkxdOarI90Se2gAkPNf+7lXe8UIq+p7AqIpE9aLAuVDi6iLjxqzfyJBHGQevW7KSB6Z4d+TJcB47XNudSnTCkgBCb3nmFQQpSxCRAA9rbg1vxLb4mLspn9ulWeJ0YrpcKSQhwKTmYWvdxRJXgsXwZ6OkpiCGsNnL1Tt+Hsp3oDZw+uylrq3VH96QhDQUjuAgPrLKQkCU0dIh1ERny/BxjwYAngxMAKxws8RiRfejweNRyICeAI2htYxXuDcMfC3JgVjXIrg3erh/HH3iQum78c3/c8kapx6uaJFqq2LscSXDkgR44y48AQ4ObOzm9kVpFPdkoe8oaPznEWZcEmwaSBJR9fXO+hwes/Yigg/9Rv9cNe7NHu7Noadn4nd9gpOr+TensMbf92bvMGt2pLN38zN2Uda1rNyhaad/aZMoW8cqeceNzRR0oCO11a6hIIjf/yOr/ku57h3B50qyvV7VfureY/3sFRz3a8w+0ddXsbG2jFqhc3d4NNr+fc2mqoqpI1FVgpGuDONroxbdnJRxfGnjjqi4lllIICcnKJRcEf39J6NGz43jM3y4adX/jf+CiF7vVsM4VBYbCAsMN4HsiV3hDpiQ6+lqPuGUftoV6kfWQ6QwDGA6bFtsGs5zhS52eFDjPHbsTHLEGhP/g0ULc6BbpbH3TQrSDdM0F6m5binzaNFeivHR/m8O5jI1+Xpv6/Z2zq43r6w/lbqw9aoUAOAhBm90PFeg37pDV8nhcvCr3hwPBS+OSgVDhDK9dCZb+DOxoRgM5QwRERiMOY+uk5JioUaxzq63umB2bBG0Foj4FItviKBfbLtoftZNvYZtrgHgUaaIAHPmj7+ET3rifpuKG1fx09auUxXXN8x8B0wqNmr8QuOffsUeyPvDd6drbZF7Ej2rvhmpVdxq4oTOgknhM1XxOzciNHdWUc6ko7YhcVu9Epbg7hMjcxcwnIkWHHKEydqRX9loVac3utB4i7fqFnzzFqrmt7ZqtigU7q6avvW2FvM8GNzeUnoNJAXTgsY4D+zDDomsIHDfbpWTYNS0AYrmMYbKUKcwRAuLyheZAf5L5DMrTwgj08cB4+6AgVECaOuaECgLEYiKP1rNld657Woyumg5YvUIKeikGR+kgbnl8AoEMowhCOXHV8TvG0vgiwElicDeZkxmD+TCKSOWoiVO2sC55lLKZA3HfDYnAuqdOgESHEYBAcDiEhgWRkUJ06RL16qHHlTY3oiiNeBxYRoJdEf07pOvEyZO2R4scxN3wZwrI33Uy/3SK2M1JTo9tvpo8P2UGXvl2k0z9n/UwWKVXEZDieOdWR4NkKES9TK6U6GWSp6254LZ+HNKJMEq3I6ETKINKWEl0n5QZpr6MpRKzeE9Mo89e+/qacp1KRezxuyYnHN5McuPMdVD1nOsnTzEJ0UDLc77ZJ1dDB7aptMelz+73LO+ptHX3iKG0iHZutxaYdYo82xLgkfPcBbbN87ahEEEfvbcGX8U7ee1gxl9OUPrCYUk49HaJsWrD0+N6LZoklFU77SEDmg284fUvx1+5U2pYHeysH61jl1Z034Mnz5bsT+84dFY3tzmpbpOL4SAIwdNlKd+5PNzBi+QsgFNxoK/RFvAhxRFKlUTIlqmQ3quUromGhg2aNjlpoyUnrRr3hJnEx/fv7mWkuf/MtEWK5taKst0mirbZLtdNeGfbbL8cRx+U66ZQCFzQpQqQWC0WpwIN2JBSRUk62t4gjJ+oq2kDCWwQgNZKBvMhGVpRonFgKxVEmkXZSSUvbLJDOdH9d2soCxbIrgTzmvn2+ApRCEBWRUkxkIaWEEs0o1byR0kJrNtpQqZx2OlFjUKPWcGMwxlJlnAhjNZKhkVhgMdYSCi1/PlphLdo6myB7wP3RAYdRjoA2jlLgGG2coMBJEqca0QUXyV2iTh0V6inSQJ1GKjRR5ApFrlPjhrSvrDfaIvxlAS9eEEghk1QKkFLIs5FiJM2fCgHkBJMS2qLEmYJIaSsGVIiljThJIBRByH/OtuQ8S9x+YMTwCQJ6Z6HxJqEbEpQobsemzMbWbNxqGwGHYtgbnXHccZxo3kyW6vCMmTQ14rmaIX3EJrK+COyW/W1tfWvqy9Jdmytl/JjZiW7KKGquMWd6cd6vLrN1175x1yRi2vjM6wAUSMiWO09GXoKFCBUmgsGdY8VLls7L/q2VqjLIMHnaCCONMtoYY0s5wUT49LJ+ltnmIHbrhy91xDEn3cDyUy2iIapJtHrsmLvkvBZsbKmlNcpoZBpDibM6a3SMVWUI4UaubtgFFHy9vlP/4bpmMKHHeKUUL+slfSTjGOHujhJ7qI19+PARsJE2ykbbGK+PeRPsSsiYYiGRsFAnff+WchkxIrYe5Xq0Nos+Vj7wLw45hDwwJJ2SkdVbk10hkhp3NApECyZYOAUzmpMpDYLnAV6i8k8sRhQfPp04aKg3KEUpSlGK9HcbxMIjf71mgAMtcp9UzD8tOEPwyUXzovfER+PCrsKL/+QV1O4eeklR5lD+N259GJRcE399yL+mDDPIuq7PG0i94bYI4m/B6JFA8LAFA2KiaYM4umJVx8HyrG9zobLDB+AX9wKw1lj9VXJCcx82CrfhXn1nJxpy/DPjT830FmH2BpHjHUqmHO+VzEwiM0/fyLaPvOK9/jfwXT/RfSfFd1rY7sWAQ0le+EdzMbLxT6IB/OYwjnxYWJRYzruZd44zy7HlNyq9DPIzQgWHGRNfIbc7044j+hFcPCDU9cQQM1ZcFUE5eUbmqM4QnBvBWCJgxqSVDTYHW4k9MgjqHNdKTvIJ8SjF5+Sii8GpGFD3LAScaP9TOU9w9KCWMIRmaELooeByYDGwHeDYU69bOAq6eYneT8HkkpaDPAyo9TYCbIaFkAAFTFHATsjtf38dAsDtl3Q/ZALg+KnnIDqEKxVW7U2SCCh7E0m86ppioDZ1Vb0OnBlALLSko/HDGhnwc5cZ7L++e75d3p3d2p0HKZoSUm4P72cU9ZP6lfqdSqRmUelUNlVBzaUaqUOb0+no/97K/yfJ1Fs6yLtLv3bTQA1KsOw3Kp5KWic5NafTspP73v42NwYzf3/8/z9z3qnu5NePwhcvXj56+fDleWecdsJRe2y3jvjz4GPwiefQNGAvG4CdNHznaaQkg7BFcKuyPqD2AFkXrL4DwHovgMlZYEwZRYuXW53CoGAqASUomukohFFJNCNrFXkokc7EPCVSaAvLoAQYaEPR4JJJSd/H7pj0ZDrN01B5gn+n8BjR/NQ1cIPW5HwuZdpOMJ/cmEYhEcrE6bg3IVezhNt+ewryZtFVBYtlJX3VsFyeWeUOpz5eU3PApCx9sdB/AvHgoYE3MChAcC30lLr8ROElhRx1yWMggTGYuTnI9uzaHPjoERQxMdc1GM155FTOU35/6lHo39Pvmes1pKmP4ibuqkZ2bw56FXsUuo/p1bM4hvtjuBreW/HQoUOLPZSaETD36dq1H3letpDv2lUVWXGf0X6/Lj0KPnueKlBeBX3UaMoP5KIKCtwHFGDeZM4NKyrmXqB+hV7FYUGui2Lm3koRExEXleepGd1ylB/g9Smw6s/dmQcUBhRUU3PB7z+eqEb/gKbrrVhRxldb/QwbtFKL/dH0A94k+UolVHxdVV2TgIbpI6kP6TzRdk1j3qIkvAMb5X/1AUCLtWZJ4o1SyM6iIvhXmQCACZh3I9rjYtHQ7fdfcAgW1lIIAElu/s7hEwAgTTFadLEDjbLfkj04vJiO8BI+RY+DCh7ReRDhOcvLfycuWzl4v3PcPE36WNKBuHItzw4+FFH9LbefWEepUou/nsm9Gl/Ke/BKIy5V6+hPqZW1CXUjETHDhVGdBIgklGsO/3GglRw78VPUSBKGpxOTO9D4Jga3RHProWKLYQFIq3ma35ODcK3ygdvtpujFhgKW5u9DHSUldZfibbg1ZVr4DQsXgAt0JWX4oP66RCWQl/yQUziDsvmUukf2HQKnZVknhyKr1LNrxP69ruTXU2xjqtiEsqovWJ8ruQGT1ud6OPsk8MS2T9NDoUY7aTXCpxjnPQ4uEwu0wdIXHYIoj9ZLgqs5RRAOLXX+SEztBGWG+51jWIb18UqhfyQb+ASwvkjCMw+SjqqdI/s9zuUrgtTh7p1MLrI6deeOS+TEDxnaF44qH553tsptoq29Kn3RNss122bSfk7GGlPp8dCOdmG5FDj3msF5vSTgvYWgsWtfxGB72cqXk+QyVQYoTWT7iYP4OalQgUaA2xTDffIRgvjFDEpFs/d59LDbPhyxRoM6d+2V3xv2i291mhpqXrVcAHc4WIJMmdXVEMIRd+vGCKu/3VjK7qIHWDCezejJg2omPsgZX7CY5RN21XVy3uNyIB/Sb4A7PxkRwGUHGXJ8l3yWJhNJX2gV6uRpkHseNP25MUMqBtuHUdFI41/D2SJtpwuxvvBbcUBT4P1KP6EaKbgWf+HQEmAXnK2T9uoqumQuJux1E2tgDd4W6OKGYEZQdU4TFiJKyI4IqwsGUXnnzAxd1FV71mfx/hqPqvAQ+Q6Dn9rOrrzUpSitH6ByEBnIQOALHKA67MST6Jsb+UMLWql8QPtkPmS4ueovLagclN+/Nuxm3741lS5bUh1oPqRoJ9/TkcgXKzg8SqhWqFylUn1W7sCteARuWp3y2+GRgKJaJ3aFlIGkbJBUdQzRfnPa2oF4wgw4G1TXIFk0+RDhfDcv1tGi0W5aYA/+ET2IQ1mLVRNd03f+Oay0Uo4MlYVvutiU85qxsUR2tiiE02CjAx1cfB01014dJSlE2O/NC3jIdLgTf4XjDinlFgGe/LyZb9HqVThwIc0YJAfjKI4gWzhqJjWkHBbBGsWIy9Eg1MFwG58sXpViUUeXxqU2OrifWm4rWZqaQsakG+C+NWzR7nj9JZYTAuvFmk1v2cOM8dy3+2M0pMuINopW0GxHP6R94tq9hiwZe3fCUY+SSa9hGcW8Yup3s2KCUXuSpIlZcECD4GEtcyhc4pPQaa7LVSZ7tgrnhjiXTjyLSoq1c+3sD73ea5OkihAQSZeUXTvoGWs8Ftkutn3UgQnqOrBZAHETF9OXUezOpAXieS2GBAk4sf9EaRdqP5vhg1wkwSi11OtU1EK7IvbV1Ih7eg1I8NMXmgnKiDRqA9B3efgUsK/Tasgp5mfnjM+ybhNke/lbpGIeMRGR91o7ssFT3K7+QvHUIN5srGY4LBRKlEV4q2ltlm83kUE7oNEoIwGOuPW7fjlNCl5qaZqXeh89ZkyH8xVJCkOTtSggMSK48h1bWAM0VVYAkG+zHtHgJd7uyQSRgIjPPxN8wfJc6MYMQv8z4bC9cdGGcLTx61Rxnj5wqTuTEs6NTlckaZLYfHPwKsbxyglxMjdZZbcbdw4wlJmKwGzECtXIgWiq2jQ5JEulkFWQe7kpwQWX/Y7rGRgjxoeV1mHnQt934LbTnmQIqeLOuvhU0Ej9OWe56dnlgztMqTnkjloIvxDOERhtJdUrDv152u3vf7Sh1rxC2i9JsoS8c5h//QYbg5xRVs06Ha6NzNL9xslowttcX1UYJfBcBGHu0bH2D4jyuOBLc6rujN/l1qVcBXdzYmIqfVH5GUd1luZZ+oG06iql6lJ2UFnB6BcaRsMdXvLYAbSQQcp1EL1Q2u3VGRIgD6VTHLUa6uU3adZtBo/ZP4OHLNzd94Ds+mHPjq34GmKbIpEmnvn8e+FYzkd4Y9zt6tYuNo3sGB+nmsUxBTNc1etNtCHUyyNRgzpaMa4uOdghIavkkICISFqD2302ISTC5nHAFogdrjx4K8qjpFPF12N1vCTg/QUHUlLM33+M13jbKg+HH1CSMbmpj7KERiOPqf6cXIR1m0QQbQ6FRsFD1wWr8iYdboz0ZhNtCT3v2DK23rzMd85FXKefQGb/tl8QwEQXYph59aK6cFQbbl8IJMeWSVw5nB/icyTwE3DB0ge9E3xp+DPz0J7HWNnD4D/aUzxsvxtcby78MNStnSehsGYz6+8UEncqmly0OtOOB+K1Dq/UJICRRavSGZgqLtJPcu2gRb62oLnHSVTFFpuz36SyZ6pHe40t5r5Ss5GbcurQLvQg5DQl1axfclWL6lPr1y28Ep3C5mErbzxvdUspPc95EKCie7h3UIigq/+BWrLYI5lTmGjU5PSRKCc+2/HpLPYHSTumMnn32YIpO8lzpKbL5NpXzZYK4dK5mD984NKBeCMVcHZI+9mlEHkpneJkwbIW6m+QlCxoV5rOYKpLd2i3JHHBRjp0squVVi3qlHJ0HNysL+mXXZMpEypkk6iWeFhWpNaKC9nY2d2vpJDviozIqfrO4qq9uXfw7GD2TS6JSvBtxdVQmCaUjEa0wGAtrD/G200AQr/OOZFuAS88BEja+49WwMb6v7pX3aQx7CAt8iqhgj0SxC7+TAanDgGLC0XGFqfpFwG9z7HKxDQgdiDJXTfaVKvdZMm7RbwEAOlB7SmcfDGg+HSJP+2+BL6q1wVFJnnvYdwY7ivv2L6zvar8UmgvSSsS+Eftn+J9yf2UrljHHzcGzmH691jsFQxEdG+fPKOmTlIkh/0YJw3SyCBofJE+9KJ0KrbSQ2o2p+KDMvFXCZwi54VLCOYNoIdVp8j+Kl2K1iYe8WCe7jGU1s09F0yaqQEwQPX2sDWK932tQFAI2DarW0/V2Ik3zsGPaQRExRofOPkun6vW7cU64Cc+y0uBOuPJXf/oqwyHSBHf0k/vDZKAbLNJw/XceYJl60dh8aojRq6qUYHd93LwoLl7LauASHCwmC4CUQd2DKdP0KRPwC7SgfFTWTuWGdZzPzS/XfRq9ocm1oezDcspOxbrBzijlkTWEGS4Ofq5O1ZnPUbBv9KK+tT/iiiNOOoInOYTcw0SmRgp4g6EoCAUyNASrf/a0V3DMDrIf19uqg+UxbdDusv7InSCNPaiLks2VoklMh9NAzdGbYU9TU2WKQ6zbYpdNNJTWMCmu91uuofNYbk9bq3dpVg+7KCDyU632u8i2iyX20N3+1zeZ6QLSMbUdibPINO5g6PXL2ynuo+G30paQ/0rpwZ835p5AVw2UiNUB3UKphXJ+cbcvZUpEyBeZtIcJf8UU2OFtVAx77v9E/iFHGaJcpeQy3zDnusKOGnerC3wykwfUmr6ZKqDsqFgxQsvEe7L2gQmprl7LdTH975YNe82crgomwpWf+8TQCuztgD4z/Rr11TJxedSpmwrEvuP2o+GxD3bzqQU7yuqjGsAWnDXfPd9y/vtDf7AAq41zM/0UN2/wuvQgUl+fRngfOb8xQnMKlYBgSgvH5FQ2zgcPxz4unNBQm2QsCS7LCKD3A1B+d7uHvGe+kCRuzGg2DOvu4o0D4LUakhHnl9VRZ67+2sGIs0F1YFfybHoqLfrcgoLnlxfZwVxj6wXLxdZfSy3NA/Jz1Y6Gau/OadnOTEiQ7lI6c/LFlV6ymoHB1CN4Du6lIHoBfdQRfG+oFmbU9GrKs8uTz3jvV2Pl1b4bXrRF2GCDik05TlpK7hKJDuf/cK261Jph8DS0VINOLsadhftDhS6XhY/b7j8kpiAiZqE6ppx9L4UgOYz1mZZbonBpmuaku3XVKRH+VR1eInKSRUYlaYiWynJHO+/RHTXuTNT34/aANRSbepUXpPV2yH2YBi/FWJu45BikdKFq+fHfHkfwgi0JXJFBWLTNNfkmBRBlt7BnPLzTp3DXWQ0uhAJwm6m0iS1q8FS5rR57nEjbREgjerK2erCbIHrQ/1ps5KPyPJzAg0Ss6NJpPYXyLhmg6KSMZ8KCzUaWzknlKW0Gh1Wu1HDMOp5H/5+8tKXgyc1JqWcRTupE1dHMeWIEsRuLEjtJZB0Dswwojz/wJoiIpUT10CI+aLyCQtWKi9HCrOba3MQHxsmk1epdXr8iN5pkh4NFQ0V7ZC9+yG7UdgRcFZZXoe++cdk6EhOWWPrjS7FdT94X/7gG2fvid5tQLPDPGIevjOfBsj+37u196TTteS7vyLA8lfsL+C/K/5HyJKO5skK8dQspKciM7+8vtQjet9YkMg7T04zYQdYokxCAYupI2RKgLK/VpwRFJj+FoqNnugMRKaye2v9ekVNWQ4s9Kf0xo9MmDRRCRMou7F+Ralt7nyDr2iBKlQpWuiuQpYuNlUVtBGPGXc1YeEpB4KhvT2lOdtbe7ZC1c750ooS5YLXAxBPkC31TdRHz7w5GSO7RrABWtGzTP+zYuy7Hsy7ZBTn8bt5hoO78twPPwXC4cVLnVX8LGjkXPQdC4uHZMN5kTax45+R55ISQ25unldePOVV4cQ15VZrPjvWnqUHAwtzitk6G63JEFLPa0G8Et+kGc7Z/uS81v6wZV+HR7goUDlH6dPUYroZhSluHNja3Bvatc8z5e7Is5ZtrqLNZZPLtdN6cwOTpZX9a1aBn1aPrHapxhlOxlsVuBTZe1y9obvoYH3vn2UDk9MK2zeEnHs7O4J79kYm5zdhRmkOdxlOUTQN0c0NF6nnT7ZUSOD4/WPbwIKIo80RGYmI28QRwI9S+tB5vnTliqRfXzSYeAWvvm//VZVBuHlCdCdyDRGFfHzQ3PSv36LDYPzjVsVHBYZPrj9sJKpB5UhkerYbbP2mE5UummBhn/CNLHpqQY1bFo6EzzEmGPNRqO87wIbGWW4UVhxlYq32jTTMdKImSaIKqWv94INaH2+xt3K6wuOfofRUcJb42sx7Boo7mvsjtr2KtohsXgPi9tQhsnmRJe0bp3kajLK5gDy8Yk5rjZDkhnWtTLcXGxk5bS8mWoxh0jUlEjSAD2qD4pXFlUbs4RPzlu2MuR4z7I8+1nC4S6gJG3N4hZAw/6aucYi9ihMZueaoi28OuowGibmQAH6/ChWEuiROriX2alNA10TIUZkzFQzGuIULYT80sj9q5+fkuOsEhrxKTGem3RXASEQXXlCY4wbWui8CSqEVGCIzxl041wsw5x9b3pTpxvJFfWumvhv5IFhSXga7xSyv+v0wCA7/Nxz5b7hs5/DQ79fN5v9Hb9txF+R4R64MF/eN+i9taImJsW8Yqlv8YfHl4bK2U4GtfUE6o3HrlaJZR0vnn6i7uqWByl3/dHbrSWCqu6y6pir/rvt5Uc1R3DFc+TMfIDZdKKv8/jQmkvb9+SqA+dE4VdXifrzo4nwg2fay4aWl71EfKB9OGA7GDwN80YGwXPh+BayE0Zvyr286T9XtqANXNl2HroM1b/bFGD5Nykte3xe7MgFQqFWlRWWtUGohqiXmE5HUThOY1O5ZgfkTI1FUw1oVvxA/iQjTy4kOcvVEu0YhahQYwzanFAxp6fzU6EqXSFfT29dXop5RXaDXuUTtsWkG3evQekoOzM8MIahqqCFkMTXW1JpqgSDe3JajnVZeDc/oyLHwClNnEC29b0lqREQLG6DZb1uRQqhPu+w8AqUAdHzN1L6+vt4VNfyx0d9jWD/pQei1OcmM+mPs80xLkbGkLLUGbgqbTR3lFaZGwKAhVXnKFquqfUIkVOZWONH9jnmuVI29pwCaHg7lzmoyIGadxR1jThNnGhaDwtElY6GxMl4pmYJwixwcDdXGxhp/24QVHtYxlgA0TeXnPIpONJlodkVdo8KbW5Hq9vhqMbmI023CJJHiUOe9HE1DT/+CuT3ru6yiH6nF+mpLg4k3WJKGSDFWahjhgSF8OocSk6j4hQ4HuIIipDQYhHdo8w1CUkCHCb0OgSEcPoQX3sv4hkz6NgP7lER+AjA/EgJj5le/p1MHZM2hBjnFDLOAYhP5CRbhBoH/NaUyolDBWauWHT5d/+izL+VNF/66dTv4OgiO9Hi/7KQVh0saSoNhnSvbqQ8HuMEwv/cftvoZsUiHDeHNSZboRNQJqtYrEEUfKRGAAK2iEhXpqgobqstuv4AzjT+DpCQDvQ4ylzBzVGEmbGbWGcJ509shh7UN0k4fDOmoJYhMrUZk1BIdRAsbpRqvtRn4aNW9y5YXq2dWaQ2832/8FlNUpD8LxVkSLag/bn9OtKphLtmTkqbPLNZLcmWfZ5Xkl5hawYUENIUaU+mWQJWqRDQ+LfwDJulCJVlMcaENYo2eQ/Ymo+sMDUEL0lFebmoCTtok+o0E941XKZRpV/EwLyuEt8RXopJ/egZOrFUqkHKGPtud9qP7bYQoMVxkZpSvNiisnKlCY5s1IvOjITJCtqZz1POzsNvfZdDNnBCILchYf9PwIBvPN5EEZjjbzrfT6Z3KGVuMW9SyH0AGDm9JMqESfT8xIGGu3FgeDkM7CvKNj1EtDrwzgivrzJuM+ZCKkn7CqunMK3sHB46K/ecXziGiiO2LDtaUC2sGcMVW8swne9EoQfeSA64yUUWxqO7VcTQqdfd3s2zkyCCuHoymnY+uWTa8dLjaXqA30mr676+9Xw1Aj7Ju6eYJL1SoEMH9qEVS/kUlTW6zcJV45Q3TrldplDt/D2WawLGluZRNk4wd+sx+Bijedj1Ek5hUa+nnvVxrh+c8bVAlQ8L0XEtq+gHaGkVJY8w0u2ItdSOaCn740/q1FVCKqBMPUvsv3uaNJYoB2oaJTIs2SJcg6gH6+SqOlVN1jr5OJUNC9DyQpNV9gyV91OucdpFJXj568WHIb9Ix7RKTNATqrbn4ty09TxTPyYoFbmNqH8GyCZ6UqTYJaREEoQeNPIUQSv3OMbS9stJQczCQJ+kMF0u6gwUb/Ubg+FgTxKVmIc8tOF7ORf3yd/XvNnCZPxLnuv+03Tm+UdhYoa8YzJf5ioUgXJHF/QM2pMC6in4KpbJDr/tPp/8iu6yUEKOFf9Dr2jsLB9phJz0E/8HVZzEJu5msAZ52hj/AYu4mAlLLyZKSe1NmQhu8G1LhvMA8gU359DtTSrlSNMv6Y/sYB/o++b8/KKZw82uN5dkOtJOoJxvQ0vwUEnrmCrrcLGjgKkTQdXoNG+kqrNd4Ut8Whrv+odimk9IFbdUKG6/RpsLv/9QQbO2jEw4YmB+fvQIGDxLPpqefJRLO85jzl/DE/Xu38UFwdGF15fRaWFQTzoYpb68sr/gOkwf5bPZsrx63Yhk0jSxb5V6kqqpSLXK7STNtTO22L1TVVGUfdtalhNpFRX5JJwKLOr1FHVKjsVPk9YPX91mnFBz/CIZVec8kF5TT2ZTPl9PoJBMO7sTSNpFJWU3urdAk9akT2zMJdwiK97k4wEk+cO/V8sPN+szqbycdujfaWmP4ohbELkCZr6w2A+yyM3O9hTpY8sfT7v8Hqoe0R2TfcM5RGHOEsr782ZRe9B9//q3/Gyg7pSY2xygSs40okknMba5YpBp9GE+kN/nCV2LZqJA/CuTmh+a7i0uol7hsB1Hd1b547hdYqisrK2at6J6RLIAsWg1tP1fvtt8ECb4is7lAyt3zeGLdp7xcnyXM+veFjmJIy1K+ZjDXKHM0OfIvKNQVZIpxZ8EXRgL46deVuL7F5NykTxnYfU2AgMObk83R8WMn2ZxeBSpNwNsaigtFtHlQfr7BP7ygmLNzdXW63DWvatK0xSJ5c3GFuqVaBKPBaJ/UqrRN2t2pmfdxCtNLIJr9/KLcwZ6eSjBpon3InJGWnJm8sedegDqvGIS4nqKQxRAM+Ly+gFWCsNm2v9S5F2Wyi7l552Wy82AMHLo3JhgyuvWdpIM7zWrB7FPDhd87pssGulHWIfMJcsVN5S2iNb0gv6Di7pZZpRhcrO2nnaAC8cRSZckx0ah4uOvpZB5GteAwzm6owrCuDTGjd8UaDAFvDUPC+lKaWH6ZkCV9lSsvJFCeQjaRhL9Aenypep7/3fSs1Dv8ykUcj3XGviXanj905IL84eZtrsDm7t7Qrv2eKddybU6b/q697feTzpPfRwZWr1pZrps2NRfcLUAfOnTwEPrwwf0H5Qe3e1YcPJz4nnd2CU4tMdxVWI8Llgcz1J4qQzijCL4nN41LlpcF58ktutv9xnR+rnqeY+ECbHWP9sZcKI2bsqblzRJlemBtK9Pr2VL+647MrOif1cp+bQqR+JASCeirwkQQ30A/uX7HVXTy7RKLRneBmyM2+2gaiTlthyt+tJCoecPFoxaoN3+TR6T8b+7qfZl8EyubccMhsIQshhxnNV0HV1BVEIjQ4NI8ebNFHZxQ7ozY5IVoj6PTnioX2/G7jZVVlkyl1E4TmtRqpbWF65kQjqLDNxVcOw4QYUY1QaZ1c+0bfaLfs0onmjUKEY/P11PZBqV0fDRX70RZX4vxRhV4+O9/EONWl76oKvuxsYOzzFOeN7td67S05WpbCgtzmhpyLIz/Pf5V9houXzfPFmjXOZ40TxdnIxxqYbaMWmjia3yFFSUUdu8BIMs3PzR/exOXRKXH43B/0an/Yv179wlISgrnz2yHHKyfrk1d/94rag20aNHqbhaMPl6/7/0btKrBAnYCdO+pLPFdotfanFPGunIgRRMuEdKTKJAkJSSL6Pmfjf4fkZWkJb4MfRV6mUJKQv1x676gund5ZEktUmPzGoonFKdXQ00h2Y9ZJWDeGPF3/3wXZTzJmfSW4prpiya2DxSb97W3m/asifQ4CyLsHBup0RiRz2tEPCSuhmaOBXupxoo8RbNVVTOh3Bt2qOzoTkenI1VlrNf88UWtRm9qgbQzw6Hc6a2QXVetua+oMOL9FhdSW2vIKtaLVUpYRAnrTZSIXmQG+Ygj2hotJhhqwb9vztxB0RO1O2YfPly6Jj+R8ersbeDea+aZQfHLFPIBGiyVnZPLz8okf8gUX/5M+J5IfIPHvyES3wOcUlHKcQ8eOzdATcWtaJGHNYgE01FHTcPPbPlSiezVF2Sjmzj+BUK95xd4UfoLp8pysPvqCwUCZyQVgsjwzwODk3xWU2500zy/9u77yyKEGgFDd5+Hfh06kOrXl51IOCyBmsc7p7e3iO5wKBTcA5HwAd7OmnsHHBiO7JyQG2QmAzUPDt5L9l9RoBt+e8WBGR/Lg5glTPNVG4HDevAjzbz6N/if+OaEeEzCc/j+OZnLxVNznOjvnN/Z0FJPZwHSleJ0w5bE1OREVIwFdiV5kKndMDA2VpSVplSkFFdA78ZlJaJqUlTlKahJuJiPEvm16gR1HR/43xZUFLql0UtKPNKPTFfPLIA+ZddciGs5cTPNEDZAJp/dS0WSSgzHjGiiyCxgW6VE86tXkz4Azh2H2p/nc2SHpoPEiX6NS6lh7Rhv/4KX7dXrZXoSi0WySGyvEthM99cZP4Qf/ZBO6ubxu0nk2yUTLlPzicTOpyMQiZgPfvlzZHhkeHj462pCQdydkIRmKLa6SUNFLZwAuJ9JeYojDcQ7Yjjk+u5FjNnkzFUM1spM8ixw7TH7EceojvGr9uPwLlLyrk7l/N3p+bAAHNngcHS1NFm7PA5nZ3OTpcerFXKcHjfHKRSwnR4X2wVSbvpTGLvz9mSkqx8RER5qrISXZ0UgMdNgYIhFFh7fD0vSwhTOTvGTHpAH3Wl47aws9ndidh9IWBDYLcLl+/TfAiJCi0+gT1Xszt/zO15ziWjCRd8QcyGrGRIyYQPrEfNbIbuVTx6sSCMVTAdHQvjQS6Cc1Lc1zzI/JMspIy0JHp/FWn2/x21cM3gx9t1fBOmZR4fLSACwD4Wbjtni8huScfSQi+YUYulcD21advO7v6Ouht3+jxhxJIAa+PnoCPu4V82u4Ma/eXl0BWVTg75Pc1oWq4EpHOYnMydn0WP0FFX5iZP71ua29ZamYmSsSwScP/e69wL+Mcy3JPI3GMwd2r0BYheYH5hBVAxIBcCJBWkASEEUGzABEB4wgEGQXm131L09VJ/WSX1emdrKMeucteIUQ1b/RAM4RnJlDDMqV5gxtRzzW0kVX3UKUPeWqz7dn/mtvcwJKlFZpATvOcVdqHuHrz5tt/q8tfNb1SqR6hQn1b2Tpj5tkWOFE1uwcpimNs6Xcj3M6F34INjR6eOw8PkD9LH+ZfAbGAEy0/tYHzKRZtzMTMrRTFPKSaq39T/lw4CU91V6b9Xn9NMGp4EKQAvIfPscOIUX4VEMR0QTMDnnvFnl/Gm9rcud5hfKBNZcuZHZPOUS9LZOndYRynKldW/rZzJADXc2bu/LQmanzIb3pmXVUfx/WGm0DS0/im3JEhOVVaIbuGqrmE/QHbLuglyqsI3Miz1nJuSsPme1ZrltFIZ3qY57qBk6j7zIm11gbADryXqxO+wue8nusfvKK6Tq0zEH68l6sTvsLnvJ7rH7Ga+IEuvshQ7//y3m7z1bO//5/3/FZ7pfw4mjHfjvC1OMSDx0zviHzaeS4WM3JWn5G2znaXQpmEJ2MisBdedzXLN+tuLi2sS1vfDGVID8FGkGAisYxQ4zgB5CkZ5JncS+O1LhUQ89/eI63YcpOUwEJmpJ5yaHUZrYh4j/oenl/il2P/OWxxMkou2ujW5bJt0E8m/8WyeEN2iZHJC5vAboDjSxliOOBdT6G4+1OGiGNhF4wkwxyaEjC9vuPw75hNOsX2uE5ZwO2GvOPbFbT/jT5m3qX/aBfjxzr+2RI/O5agVGlYD6eH/Zssx5/cal9NyuKzrP1mkewE8ZogHZszNknoDhzYGc3TFOnQXthJKEPUoc/DlSaGeKAK6ftbMUwWbbxzyS5UON0CQrxUbJWMAmOC4t2ntKESZoJ5SesEeJgz+D0M4UAXTOurGLaqqJe/L2VvZ+Vv9mr5n/FipzA8cvTGPW9h0uYMwPIQRqXP/xS5nsSvn/4yZSLwCf3t2XAPj64vGKWfpZ4+izFZiPAAR8r58W1De0u9MXphGz/kJKRTLZXN9lYMpGSbWYtvLFD52geibMQ0kRMqY6p+Lw8rsWnCTwip4xSWBKD65iBYG2oIHUj9rRBBrh+IZkcGwgCZxpsIxcduckfowQXXNA3aOiesW1Sl4JlcaKRzP53CM+xqTdbM1sJSFvLDf6WiTvnFKQtrJiCXnuhnF1W8sKgfEYvrLQFQ7qIIZY5Zerk9S6F3/52giMJeNf9Sx5SaJeibIiIZJZNHnxUZCXqP+3qVBvGQdYEK7SWNsS48+28ceFpJHUv/Hm/AvGarYlI89/MNd4ZiaH4nifgfS1G7zrQ7RSqqcxtjicV0iXZDTrgQSllj5vGOOBd1yO+ls3DYkoKqNRKaujtp2lVjNwXeYhsRf1SG+qt2d9zXVMBNVIBglLDefJNhXjxnsrgyI0gRJwUAw41RMDc0VF5Uu0IMOcCgByVdRUFs6Tad+oQN2GEmWJ+i+gOQKQCfdBCRIV58QItTIrkAHkAgtt1MUkgWXsFDdOCs9uxkxjtAdE6M818ptvfJVrC3Az6i/OqceLpcO7V+7AMXnBMxsRUDNevKfh16l86hENspThEnWiDrNp8xvwlKlm/MCLughm9+6FgqlyubI6TlIt+47t0H7Yim+kSUQu0JaAoe9WlbdarnJFcyrHo0USSslRlsN98x5TCjpS/2wNfY3M+TsUGPajFvK9OQiyvgKMFDQ0yeEWL7P8eqaJEMCLUY+HIHlShhDiwBBKuPNDaK7WDmHoTB7C0ussxNHMyMkQYClfDEHguIp4j6lEAhh3wxNxwBm66KRCZ5l6qtBRe1XydNEd+kzyZuLVvre7e414W6jnpi2P3FUP0QK8C1c1Oqvl8FgXKeLCe3T5U6ktpffUVKTCW/kpVJO9V7Sq0F2JWHe1Jyj1gp8aHihI2Az98Xj0x8b932JkMDPLEJPteOdkGZRSlffzm1XHgjxQPfjuxYvVHJNS5utubnVUFZeS9GIq1B7KvbCRtdRW++DOvVTyVyWFBUSydi69x8qOKrz9XPppS17TvMMoqkorOjK+ygsxTa2ngj0y64MUUoMffqYQxxZPRExCSkZOQcmOipqGlo49B46cONNz4cqNOw+GMSI7e/Phy28oEAMFWQjDhCtytIjHiMfJwQkSJUmWMiiI6TKYKSvlyB2YE46eX6hIMYsSzTQfR+KtxNvImGXKVQQFK37FQ2Z7YbTJJlhknZVFw/hiYIQZxaIAJhUPY51wLxwstt5XX3yz3CbnnLFZpSpTVbugxlnn1bs4rGXjS7UuH325Ldr6YJprrriqndfeGseqvQ466aizpbropqvAQew/a299vNJXf/0MMMhAey0zxGBDDfPGO/tLRB622uZGRHDHXTdtt8Nue5y00y6njDH0D9MWK16E3YqhCX0XZjjgyxoeAVEmErIsFNSut0ND7yEGJhY2Di4ePgEhETEJKRk5BSUVNY1sOXLlyVdASwcC0zMwQpiYWVj7cZxvt0cZDozqi1iWzqXWNTLDmnHf2HwRWcjBFEw1XQ7KHpUdnr1FmPz/ylaZlxE5YDtE7mXaxn+PAbhplSq1dMUMZmXa/WSVORmLxTfrzUY77BWWlOULw0R64VeW6aDqxL8+zSByZDBzPAercz+Eht34OJQlFmsW9bu8TtBSR5bF+zzQjaWkx2PrYlN7zUYbjofQfG084d7BQeGobzifr4gLyVDB/lJs2uAVGIBkDRrJGDxDgzFgEGgQkjX8HAyCG9j3CgCvAAgONAg8QyAQGINAcOA5AoHcTKqQbaa+8VltZPyw764X7+IsMiIc3TaskqKy2zv4HO+JVZ2/XqR+Pw==) format("woff2"); + } + + @font-face { + font-family: "Noto Sans"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(data:font/woff2;base64,d09GMgABAAAAADSYABAAAAAAbjAAADQ3AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoFaG6kkHIVKBmA/U1RBVFoAgiwRCAqBiSjrMguENAABNgIkA4hkBCAFhGwHiTYMBxvuWwXs2COwcQBisPMAiKJ682n2//W4MQY+oln/UJSZoWGjZKlaNiQnbKTaqwqCUZM1lKLeDlPR0eVRKN1whGZPLtSUGwWTja1jSsWLjGfVwjRamCCD07me98X3k7mCCpX0ijYXi3X3wfmDvP7jIeuGHR6hsU9yiSK7h6+evQ/RkZxaKcTIGah3Ozw/t95fAWMMlsAKWLMoRm0wtmZjsFEblZKt2CcmikkYeIeYfZ5ReAenYNTdGRF3nheq9b+WLdRg14ch3A4QqUSoCBmhdV7kCbnH/8Mfgp77fiYlgNFEbJOM2VpIa1jQAlW02Hy//swJ2IIGjxsoAfnjoRKlwPUKfM4U1zp5/DmHrXXm9biO5nYz8NAJ8ScWcctTOTbPEQTZXRRP9lOfhUFcw9Yy9oWQtrr8InImwIkYcv+55vzHbab19VDHVCJAiraeqlyuiFuCiO7GldvQxv96tZh2BsNKGRX6v7F+vEW6dFd6GWZ1/nCVoiSqUqUFOukEb8v8FAZdK7W2Q0xVuAEBFggmoADA/+9PrfY9Xf8Tywq9MGsWNSmaZLH1ArZA1ZTSvV9f//2rb1HA0g/LM2PZAUkOgSlexVkCJ4PVAqAdZ8FessdDsIBUEhQNcb1btNvWiEVbbblmQ1E/M8uDKJzKgUoUrK7hf3tTvwd5y0JHHY9HySiMSgnBt5iga0Lwsn7Vat9qUn9+eWpv8DdW0SQP7xcBkwKYEXAXLKQYMZS4AsBAXnoLKGgoUE2AUq7IsUIpCIKNrIFcYHwECQFhi03KKFdVq1adOtUsfWrAYXXEiH5aGO5nJDDvvAfFLyJ4yPmDk/YHLAmYHzB760FghiwEbtOEXICgPPXrceHVoQDTSrEOavMPlw9Efewo0xr0qbQVcIXV79dGL4GoPwrrHKLoPf0vVYFbHW8NmhyL/JsTHADG47wyB9XCUfb0POD/WZVxsMvunrwAwza1BBAA3CYKBqB8Q6Bn+6e4jFIeP3lIqS806JKHDMUPBA0OGl6UiM9uh1GGK8IVjIIICwLqBdB4zz5AfbEvvouz/V/mc8c3j4ZCwJJTtZaI1wWOYTQFCKynluB7bwEJlHqChk49XmteBkfKY6cSDAPeGmFN6TZInqoevx6O2FUDB+F0wx/vKHW6aHb+8KwOB5hXpMN6SY/53zzleZja8yBy1lmB+LhjEf0Ay514J6uuQgtrpSRWYaeWHYCy4nYIlNvqpkEeAiAbX/Tw1RupsWFMbviDgaPgAPZv/Vlv9mwPdmvXd3FjO7vjO0zD37vt27j1G9iKLd68zVrnWle/Sh5+yfKWsZTZZ552qiknGX/M0Yomi2X9hoYhoK8t+pwPeYX+nnSv37t6UND4l4m/dPpGj3aw3W290Q2tq69lyy+qqxlNrvnA2sorKmf1kM3swo06s2VMc6NRKRLFHX5wlIhhYRenzROCgf99AllZzOpbFchz7+nGEqffw4vd51Z+ZVOHTeBIKx3tcAGfyEytp9o3On5foY4v2+ZExyzIAUQgmliwL+hTRZ+N3uTTJ7DlxTq9aMIK1qLg5a1Ta/A7gxVWjrgKRMz/unmZp6GCPlgrNk53tq6JwxSfZHIwYLuPNt25WsV/dzgWkADRYBKfiUX5WQ8CW/kPrwL38sdhVsoH9oX/7UAI21sQxXnR0wIS4BGYYTKEAA3iQQ+LoNd3I2OdgDzeCN+ZQKZDDwhADDXQBHaZWUAB1k5yVbm8XFIuzPNup2BRYAITmFjJ19vbZndQfNNq/ypral3HpQMZ0Yk5WTP7JFou3tHRJrFCMYskPcTUeqSicF9Ne0Qt4xuWx+ppUcXHUnnooVFlP8uuRpm5XxYTw0IHDU+H4tlCCloGQgQ2bN8yWosKL2Jx+gquBqDF+NgrGeK9rU8SyK+sAiyHWm43+G5tdhyqhzoMNTj/IqDDtzMTBnzqgPfwN68CP/TGq2zBxC8FrOQIOHwE9HH8gnkd2A06lgCsIAIFRIMDHoABQgADGWrRNzEe2RKgFy755NP59hWfqXgKoAsIIIUCaAdbPGfPTccrAIACSgiHCEjk6mMUbt+bN+EFMwOmzWe/BbFpHpkQmYbIgyFnMh981LIKrn73BQoS2gXjLroExgsSEgwaGowvXxAcHMi4cTATJkAu2enLRUGuagivCZiGJ8R0qqetnYTtoteapL1s5uRH26FQ1Q+vQ3XHJkEplvPfTdXWJNd/4Je21Mt3jtSuDJJ9ELeednF9TQLDWxgNq2x5aky13g03vUyfDisKYlUw2QqpRsFNVQjrFeSGgrupEF5WwJJVM8QHTXnNg38RjzJU4p6mXVmb9p0kR+7u6w1An2JKx26xQCIQi6X+ZqvJj4UHbelojz71yY5V6i71ZAow4mDa1t3Mti6rH+yr5qFl1wHfAaB7S99xvCMmJ1K7CzofOi49ithMZyz1alpEnl3zQC3ewsZN1y6mMLcNqX72qw7JqjHnJn1X8Xfc2VPb9TAAxhrlQ3bKwI79dZBkQuUhaFj6AZoXXqHvSQBISONmxcbum4sPRULKi6Iob8qBMCLF8mFkQhS/KbKEUH9OqQKkKxp3KF2mLAw5ocFm6RZi2btfbIU+EgMGhfnBZtG22kFrt72M9jvM4ogj7E44LdFZI5L96rJUMMZggZAlwECR+CoffgoPt6cPOgb/KMJQMAKGAWACFoHCiApFiaaCFqO8xCocrSIxKgTTNk9m8dvrrDcbFFZCwVMSRy11SgaXAgVJVXBpCpKu4FzKl1v5yQiFy5TDQ64iKlAkNSrA1NBAs8yFNE+RzWcYYwIWQSjMauugDCovP/QPGbIZwhY7QOwBtC/kJ8fBnYDycFJ5OqU8nFGeziqYkVDIr87Du6AoxhXBhPJxUVFcUgSXlY+ryscNFeAmB56DN3uDkaQFMIxJCPgBjv1SAbCsF15RcHyFIFEEUgednAA4wFf0yXpTwyGkKMCDfQVDSWCIf5NtxvPM2PwDJCP5BBggWnpZoBsiIwHOcG5XhEsPual5tz08OWQke1mk04XRYARvEueNwzAm/bxLMFwl/PbYClI5ELByG1vTqnpaUHzVmlRQJoidNEX1iMFawU56tl/ceFZVoxas3JOjpLFH3TUAkfHijY2HL5RCGKVwkQy2jKGhZ+Zl3xx5ik01Mw8823fm6DLXPAsstGgLXWaFlVbpxWId/nonnHLWTShiJSJvEH0XKtIxfdVg10qFNElJsURcMAISEVqOy0sashXywXgtmKniAuHac3BN/+RGp/TSai3rB5MLvl3hCC00W2FtCWWJZnU92+vvSM6RXXKunrDpyxBXyaMXAyJiKFANq/tmScSHFLnU0apWkRV0q/QBZuDAg0A4kHw74ZyckJflVQXxE2hKww4EBc+OhjvJlwxJFIA0j4ehVXClkuC+uz45eYZXkAd5kAd5YHa4Mb3SzV5/FtBA/4uvUprXGROeVytlRNr2JyuDnb0O+5j9YNLi6R6eYwYF/5Cu4YHr+CiwwHk8OinYTmyyEownxxxofiEedfxLAG9AAkz6xBsxqYwSVx+YMVsbKKOoAgLgV3cCwDXmmawIA4J7euSIBrfqLRkC4GGOjEdq9RqMwyswdm/AWdm9FexgEqym+xeO6jEYyAf/Abqei9xVPM1lYM2FC500iXcl1mpasOb/gwRAL2kr4acHBfnKHPOb34yfuyviTyR6blQfwRQBLTHoXHzTEuE0mA6C40tNo85MdUAxOgoUGvY17LAyAConAq0L0sSQyxLsUNgApXITFcxIOrjCTqm4NIkQxdxRSKCuWAZgu+ZPRA6kKB6o6ZAwBBIBgyFmAEcBpgUsDbDVTvc9rAf694H49x/AcHgLgdwKUItuDLAwFAjEExwwOzhgebCzvoY1BYCzDq9DCgLQMb+slIoDw0SGAgcDixYg7LCcqFGCY2+NHTXIskgZahQiAXrjopDAe3cG03yL5KXu6M7t93Va/7bWWCfrvfWJrjRR0VJ7nXVXD/VOX/VrYS7e3c23/01wVi90Tv5e/ZR0rsoYCwttdbx9t/oy5PpNyFOyje95/342ek3c7wVLK+mA/fbYacg6K0kGDqpfT0HEuHt3hsW55j+Pe07SXOcZczzPTKx6BZZzLSA/m/ZgsPvCenZGqaFv9//x0FHlIEcJiykw43RQGbtVM01t6SwcnaPUXWGIEQPdPKChrpF+YBEPKnttKZ2sMPI2+ofV9EE7rrfgWI3FeXthOjOu0TwmYWuOmuhMbRtfnUrcg7fXFm/2AJv55kYcxAmJ5KHUkix3FHDv3s5Xwt19F6mz0LzqI2D4K4Wf1lOKJFVJme6SuQvbMncmbZxPr+PSnTJJY8QRzCyNnVvTxt1eNoAZSxzXuet8Y+uzrvVnnNNDHPbg/v6+b4AnRw5lOUkUhTYpS3iCw6mZisYaRhGsJHADTiVRejjLIRWXftwyPvAgu1Qm2/kDFxcg2QNCEaFHFOGpiiXZRIMlZtbkxm4sstya+VIkRv8mu8ogJDD4MUgqlAaJ1GxHaUCNbisjJIqWq2shaJgAS0B0ivrHvc0/GsWekVTmN62DienpLPSJRPLPaRgcEDvAPKF58mXaDLuVXbw1gvWQa2WpTaNf/9UjFBgQCmOiLxpox9JWnnE8SE5whz2iszghwLqr2AbHTVfRw0q370taevHVCgZK5l5WAMB7LnlM6L3o9KNcsfwh22RPxK+vSb7Jhy0F+MCINzmS5GduVLQJdBGhMMsEYV0IgoUkzcmb433wzNN/RIYVGZ72VBpob8/xiti6uj+xV+VkgxLRDrSJ9q9v0rXXHq4WGGJvUvXZ/GYzTK7egGPEG/YOrMxmTTfbnA4BFemodyn+guBAcuMJlwX5oov4wvm8gnv7XBdHQnu8XjgzHuUsvNRL52Q14wU9ReySlSqCvmKygV6/qMQV+g00Pr9+TCe8I3pT+KqvLqevEOFryUfBeUyl7XOVGmvCO45Qm9broGZ0OM/7f9gdk7DqZuq1R+UFNeBV1wFrgqn9P9G4MXVjl3S8AHalP8Oj9j5zJfaaogePurp9wmVc6+tKp9ZMyNM/cdV7OEpmvXwe5CGqWimriomyPykGDnrtdS16yWvp81yE+GP0BQPpPGIQNELUzs7/FYxw1oZXg7VSm1z5fBXE2xHXqnAwziVcNxuzaX/t4xjHOtCmSWbiMdJ68vYDjUx6RyeUblvjqdPCS7vswYYQmGvGufZixspbJNEo1ncXDnEXeP0n3G40LG7J1M9pX+aQ3s9z+HBSdM7XcbM9RT4JgZSgvXhdOV8zFEuUwjdHbwjTnaEK13fgiaNcUT4bN2RF+gqCwjCxcd3XWzNsoStn2DQhWUGlJ0ROkkiUMMpVH44ROzgPoVcyqcC1TOewVkULp971PTiiKaep46GTfF5i4/iHd4jAgLBvyDwICV4AkrLaYhpdniMZAcLi/404bnkeUYoEztQsn9WHL3I168a+3cZsbHZPo/rbD63JZ6/fKRoMAv4g8iAefJMeuErY7FJgtzh5THV6u5q7a+iTePxst7awHrO8v4Zk4tDdbqrddbYaSvTJY+cr/Fx/7emIkVvO/kUj0zm5ilcZIU0kMBLVU4kcuNhS7eH6Z+sqDM8BdAqhIlkTrfnkgaUDJpX4KwYf8Fllx4INK+Dl9nTVxPpotxHiav35WSOZ7J73gpX+qR3nfbdElp8sdCffzffxyrxwHnoSF+AaNrHHVGdG9eXSV/NJ3nmDWCiCQSjFipWOUwapICxbLX0d9Tq+kuRJ63Mf1tcrFP1jsor2Bu3ABZtywg1PIwdVNjy+LxmpbLCI8tyjGW/uDbQdKWVOj7fkaaVbb6EbG6ym4dpwdSn7Bs8q8ExZgByNmCxha1M9fne9j6/9b64jkyP3TJ9HviNmSXSMcA15LbR2d7lMxk9JRYtxe+lusKV8Ql+6wxQb+sF53OiO8puretLpgOsnitOiS82Gx/YQO5dZxBdg8jHcFCs+L19tjxwDJonJ0hN2ICkr1nO6kd/U9NAWJ4M75gWXthVKPodZ3+lmsoofd5LOO8933nhRmu7QmyxcP/0ZISE4OfyNyC7Yxuw3ApwVr9uMdOTYEhulNnScXtNMOIZTFh6F4BixWz+IIhzScNiSyXqsjxE+OR6BaMqeuHJB3/1dqaI18j6CCY676XMKVhTVQEE3mSI6sIV3AWyk0yi0Qej0KbQzQOc951opHVg2YI4w2CmdP48M9jOPKMkGEDpNA2TD6/jX+D9O0bUHu1FRksi5ZN/kDYP57KRE4wwCzIDwTCiPiCv/mAt79uYhgPw59PjbpX7sMjao7rScW3RJ97DlYYDA51wd2V65IrP2SkbRXYuDNcfOYGcp1OsuxjA8uCBcf/wgzeBVVSoTNDQYFQf8qJgXwU1wIpk4oB2pjbyHS2k8Hq+GqgcB32NIBqWvv1TsROGpjRZVLuX8Qaix4QuaDsN6uUoiCChBpXSpe9FsnJCUWgy91TnQG/NVTUtTqJNiKQiLrZaO3evl4oKUjqycZvL09KXMftCai3iFCQ9fMG3wGNLgFTf79HYn0GSGXK3az3cREILMd39qICTFn7tD8VmlcobPwgV72ujYonhxKEo8XlPdZ3MjHIjPbOAh3mhej8mR/T+hIQTmglTGvbhEP4X5aL38NShj9vGxC/KJaIuj3fKI/2PeC4+d2R2jjr1x2AMdcMduhtxjBnnlX48vEwqTZGYfHPsPA9tjh7n2ZYgRTTscE/CTOf8sxK0KWvruW+N6v03a0kje0FtEkREHIwxOGf/swWGxPXK1kuEF9vZqvepcAphe+ARF6cWu/fFKcR1NkW+nw6866E1+Wo5YtBuRtC5a+Po3vkD1KnrEamEQQ7lT3r3B0GhXySe1qd8krrCiKQIkLOMBKUzremiH4t4t5dVhY8zak5HJqxavCG9/SpxnC70mJ1a5qtdefw8aKSGTXbXLmf2RtPtIy4uDhMpFqg6VcCncJASrpeaOM4lr0Zac8vrBq7fj/LbCc079RmtLJFAbKOK2eI6wl7hmGLNWY8gwk5UHVEf60ZZxW4wckWo0t1MvPjSXkVtpkU8C+SQNucOwFn3rtd1+lh7FAXbZqu8z4veWhHD5649Bch/oL8xaIVX53hpXy7BgDya+BTd2+neD8sbXTcuzKT8Z0Mc8ejQf0ajySpm4lsncoKZZ6K+rXeyTytydLqf/ysPELXvTebEjnV5VAXZZd/pfNCn9wYZn+PlivQZtD+Qb0Ran0A3OrN6tJAf+W55YfqsTy9db5aC6tkTR1TlSMhxhJqXN62zp1OUxGeQunn1S7emMWeeC23ecGY9U4LlC6RRW6meWp5YdUYpPRsnb5qiUCNqWSwNqTewymrAQ5eZ8edI5luQz8Gv8lD4VJMTCcJEIlokNUn9mb/+/DnjjSpyKA190mSIkGN9D7YIVwamDwDgnJFs/nIGX/YT6yDMN0bq1iLuTNUOWSa2V1C1ve8wNC/QHOXHJHSpO1KemTg+lMOPGlmnXgavTKaXDowP2Gz3iTXQtR7DvIryoKG/ARSz6UOk2pk/aF1TM8EOcMXzyVfZjfME3AztiUoicJ1ZlDKu46f9C3Qh1WJRPOtMJK7WpfKwmHEtYtR+SHve/JESl09E7uw2P8CgQyQW6UHCQfrbQAn1d+1Gw46AWViDEzt9tWbG+nu8KZKwpdsvj4VYTWIU94N6cbSJQggetZSTzbnt+d+tk3dxg152fy8fFFWXtzD3yMPpAjuNJfFefvTf/B8j93RSUpHm4/YZqAphkjq0eK1OEl4SDX562B9zfum22lM1mKqWiCCabC86PEqDyV1EiynoDT1eT66eVLuWMVG1757NHKlgjzaVzPy+aMqmbc64KolBULNMyqby+gPwJBP7naaFS4rKZs5uCFb12ek4UzyRXKOPzhFcTDNaGItIjcUO9E8fsSS2UxFjU1orCSq8Sg/4G6X9/7KPTmXqK4ugjLPbs3kwwcsWZ3VxZmd7qSspsraxMa04toPrrYmP99VRagE4TGxvvuhaOtmFzHd6Rc2uiqzU9KLU5rYBGnkDW0z4GNQFaEH5FlckKMypMybmnF3TJ6baVq59GT/952eLDrWd0NKuvILlJEZsdE8aI39i19tEZepjSmsfz/F684z5J+E4WPL2mdiLwF1Zgt2XVBQajg5srC3i4FMZqXTd1siHXfL8tMagvZtGFDopuAaMfCHDO2qhdIOi4MIe1N0CiI6g/ZsntyUJtN2M1SPqbsG+/wm/KBu+qJbWqhF59b5OqFW3wwXfsUez7gpSVx6qOHa06Wt8gx6vA3fJZVGeQE1HVi3P5purmA+qFTo9OEG6SstkstlTEDcVc0D/hdW1+Giogl7goc1V5eXKTq8atHGpqlg7VuLP74K7wDTlNybjqjVzuML46JQU/sTfK/48Ea3vX4iASwoT9I1kXn/X4YCegfqzYu9+ts91iCZ/zQxM5PyQXvDCKs2hac4kiwhWplhRm5D/PTveIBF9D5XyLUXwEnu6VkWrRxBRNiypRlxJOz7he4i9M1kaGMW7aiIaI5ki9MWABjfsikln/M9pUtrael1Q7IwMIlrtWLF8hr4g6v/CU61k7DssgohCzw6/MGtkOhD/rSuWqfEtiXN00dV5UEUE/S19GkYh/fs8LN0VYaUeUJyhVs2d8uxHYJMCUaHBtokpnSqckk9TI6mCfJRtcQVSyE4e68d0BflyWUl4Sn6iqq1YlROZxzHZeC1Qn6tQq/5OH0I4FBF7c1AbqvG0F00G+TyWgX9ZM4mgSVMIQ7RJdqlEpMMvUGne91OasE6vS1XK25WryRwVDK4yOSyjnKIi8woio6Xzq53vd514/HKhZwEGyAponV1VWNFNYSODfF4trX63RYQfOnOyHul46LInNvC9Jqtoalc0/RTeJ6XO07VpOn5C0NRJxq6zoreYuRrstev5giYS4mEaUiMhXigx8D310zfzofUviQOI8IJhf+bjy0bGqx1VAcEOGZ7tFDT/+J+Ia6PYOeVjjER02QQy6HypIDjiorPZuDDZUNOYnhy4O77wmHSeR24jfS4kcJlsq5DE5/iBcXi0hZ4oteqGYd/A2yq7jSVwZFW59RFmFOkGaifvR6zwaiWaW+/n/j7OJUuKa2zSpjjaZMyt0stUV19mpz1F1BqbkIVrI+s7dGTkbm7LVa+oa12mLU+fJirPDZ73ujuxhSXgWuBmR90MjVli2GPiuuen/iE3D/54/+v952WmN91dDxMsbesXAAaAeXT7HlS0O1pahkNyPPEOULi6nWZbiPfaGrg/ihW7UOjY1nSw0moXUinIZ6E4TGoO/BmREJ4RWpkVqpS7S7lmnc/yiG5dlmYdrkkVd7sLp4Wnh1WQPeYfMGQCG66dkD21Mbv597FXDuqS0/sGWRFFxpkzvmNDWVrSAv1aPre5ErP3Q+eEwArxdOdbfiTg8fLSA8h5qY2M7KbFyRap9qL3eNTSc2R5d439G3g5SSRJ9gVpZbTNzqpJj9MEjrJVzwVCvkqrsHesNoAb0glKTy8Ubd/FczVUD2LfoBz9O+8ghUobalGt71+RE1ZcqQN/2I6Iv377gA9jy6LdHJz9s/qwG6WP99pBpYN42KQIOd8CzOPa5Y+ObQ+B7Q8bHulOYMDcsCY44JAFXTkZQETiYAWaAnHPHToST4QDaBKYFKfPAndJUfldK4fSwDPeMMIT7Xml18UOrslqa6VemLBtQtTZ2lSNKo02KYlXZ6pOnF8Q5otmVQDm6YHZjhZSarjVWcBN1X/rHfu/UXw4TnAV82RC4U+rgfJdQMLb9xMi0QbgnfKl/4UlH86ZIYrJNshDrcu2Yatc6XDq+f+xGa9yvcbE6eXTwlhbwdSQuNrdZkcYxe2nxF7o51ewIzhkdmWq62LiwRxtikmtVWdVSW1RJYJmsBZ5ICqU1OMiBl0+MVNgjQxNATX/CqenU6afAuv9s2tZZxpLFy3vazvceyLBYLIpLNEvk/l4wf/TD6PD70f45m1UvfLUcDlb7r6BrM4hpHDswur2gVbBwaBoNs3jZGmF+y/YDoyuz5sh6VszE4euXLYopW7KkbIlozfJ6HN/biDWZs+aAgd1rwTqw9VXdncytyz4u+bjtbhGIWrdMsPHlPvIA7uXhjUBIKEp/kTgV2l9dPpCvfVX+qqxmoAZ0T6C37rpr0GXv/NcUkL9cmzC/n2hZEz0vGqxZO2gcBBe/PJ6z74NPqYhre3YbDWKEZVUF+dUVNCuiGvlOIktgCxNUrinpkxFZMJz8IJ+T6O8UThquDTTJzVAHl1UQItVGKsXgrjmIx0YlWyXq7IbFC3PDpuXFaWKMgkz0gubPvRUG7luiVolPlDhi5fJUp0PmBBFejlaVrrOg1DJ1isohSyMAUcOtkwxeKjU+MoJqSuEIE8j4KQ8SSeA/r6zGxQu7mxbn8b5b/gQZgtBSd/Z9bqabUdeXD2PaNXKz1c8hTdXJI1KdzogkIBfaa6JUtU5rPt1iNoSlEvY1NGXg9cmdaktnXoF2WnOcs1LcbhkPSb4ApuyZuXfl3pT4ZB7fLLG7gtQis08GpxrRTgkaoTNngq/C6ATBbISPcQY3UV3eHp4bV4zdM+NMHkkTp40O88XwGMhimzAqr37pvHkNyxe3hjwmWyNTlI5wxtJlIxxyJMlCAHfzRAg9wZP2fn2I2h0qc1lKejXqAbEolORQgd7PveBeCLOXmXA9BCsM9Q1hfgsVfgW8t/4RxLaPIziqTmHqzVWwkp0yWNOg8A0rmCEMDQpmvQUPhoLsEtjahOyjfcObb756rNy+5taHrSs/rwT3lng1RoV5+bmVJas0casEKmlaEq5XE0qIooU+x3scIbOZZEJ+fniZG+sMLUNuTueDGcKsMtty9VaxUZbjNg+VGI+isB8ALc7IrjBaCjkaVT7HZOH1XrLjprZpUxNbtAb+SzornJzgwed5kBLCI8jWT3yVMh1MFubXLF6Yo5yeG6fhr6i9iMJpAi/14qcQzR5PZg97tXHvxgE/JT7+MZ85QooPT1YkgV40gcpEya2SmJyGxQvzlFPz4mJjrOJChI9pBr63JIePJWplJIckTa9UuJ3JYQ7QKvRlLsMu+v4Kyf/19CY+qZfZRMr2+P/LEfDreqXMXhyaqHJjZ0zdX+rPkcwn+1qsMLhO4C1OaE0qCs8iPRROcjpIQcw0AtZk+QDTCzAgrDrwt0l1HWasQG4PEiWZPSxxyXJBY35zf32/R5AGxIUwm/EmL2/DW5bKFSpzm0v6NKoFYgmf4FABcD4Rsq5Up1rSgEFg6gdVZfp1EyHg0XEZVo+z+2olCAlWG39LGuTCo+sHQlZo3pejTV5aPMInb2Y5WY/cEIeM3bGEgPDz0mIs5dr3y9aE/ACOkXeht1TsqNyxZQ/6R/KWxiONR7YA5uCkyKTFdQwE+35pSeSau6k5MWluoS4w7XTb9w9xQX2H+4+D8d2Z/6W/aeov/NP+FRSuTc+hqmUDwSMFoS2hBeeCVsvUOdT0Wkq2nbZMsrIN1bZSsoxmz+oBt/+sulkFpvdkfXfBcBmqbPiNm02pdWVT1bLVQefq+S38+pHgAZk6m+oC3ss7BP60U/Ma5r/g0Lc+dKjI9D3DJ+LvXPo2kMxrC/hjy9ZZEiKbyZaIuEw2GVR03TcTjgkbxacYPHZgfGQExazgSPWEgs4390Jy3AWfs+Nkza48RXNOzC/JbpB+JaKAXCCqoxv8goIfSfMOVR0ys/JpAfnTQcdP1Ulci7vKnW+LzijgA3pDUC7ue22Jf60WB1Be59yxmt7uu0ShTpw9b7HcUvlQKN4Jv5tbH2cnSgMD2/sCKfMwlL55lMC+dkAbmsHpP1Kd7243tG/LVi+q7nB3GLbkHqoe4CK7uOooPiOAqktYtfShPyeu2p6pSCfP5OX4GgO4/HB/v8wbmvtGSRVfTvn+8kN2fFN6cXgKAV+6ZvhoYGR9AO7p5u+JJkFCdQSxQZJf9HtDIDEEexf1A+jcwZpOpy1hhSyh0affCGbVMV68Yxl1YOf0SSVTywzSSdkqQ9C3Y6N+0SuSWptqs0el6P0XzTd10OXznXPCi4rC5zjtEd8Vl8yJSrJ/F15SFCVt5xzWwvrQHHGaSlGyU1yrjxPVOFKrJXHaalHcS1Sn1wtrHc7+DgPnLulscfo/DCR7bWgweTeHl2Ql696jAx9Rqa+PMozfTLn41NKtBGIKkU0J8gMc//13qlRzt5Q9q6L33/obc0D+5PbALeB7Nb1q7+QqgJt/ZE5KglEn/fRH06nsvE2azfIXvLIgllQkXxAzbXVf1ae//y3+F1yZqUgUCBPkCppmF4aFhTmlkPdeBPJcdnkIq4PFeX1BIe1AWPW46ui+86G/wlyDMSnVlbfId3H4YiJhaJXa+l8SM1yXpFEzUj4Vi7cD7yvTrYlqKbckF/Hjg1B1tsEdGo+9mAe34FjsjEBytDTcKREJycRDVOqlH2TGJ4l0YPz4+mYWV1Ti82Fo+NoWoA9hNpLiPcZM72lBcxAwgjRkTS++V6M6IhF5Eh0qAMKmOehuuqPn4SOCJlusqMouiKguFut44OOMMKtaTzl31XHlkS9lPZ7Yks60OZ8YVgwBXKp9e/11HzLRK8Ow6kykcVULKAtKy8pJjM/JSXNlZCfJrKHCpGdRsm9M5luZ/B6T+Q7Eofffqo5eO0XVqv13PmMEVMmqRrBm4WiLf3tN+9vHi4xyT2rNTVzWmLu9+T3d9lPCT4FNhNiUWNvV1YVOIt3D3tBt+XhoDNYTOX7k2+g96RWBEy6/BMwicd69y/2gDWCxKYHEIGlkkjjsJJHQFug/c5/MeN1KV8Q6YpTMgmOowGcsdbqhixTAmzARH/ww1z/UcIIY9CBUkEzZEF6FbggxVTTmOwXrIio/+U4QSW2k7yVEzlnOm4TD4pDA1K1b38VvGYgbje0krIU6ocOEom0/BmuAdrR+XVJ6/yH6G2hTctMNpmGaQfCoM3HHnGlzNmhqK1raEsXFGTKwW2OLX7Fi2fJ461ty2dJo92D8Mqut9bHua5Al+2KFWmeYSAekPdkHpiOTp/oFdzrlQu03cff6jIw57ysCfqQcoJ6u1Cr2tr36WBv4B/Uw9X/e5jYJNSVGN4mTkLCkd6y33v3d5Mnsa9Lr93jhl/Sx10FWCnOotXsLBsPq55JelfGipLZcjk6diL3b4nVfTeYNUv1Gp5bOfWqjBG2d0f1DYPBMOdt0nhPUK4w3Z7eLExObOXobmCE0lUerapzxhQyzJU6RSbzWNNuN14kcpP8bTm9PIEZLk9hSu1qltJVzEmE5sFDmLi4vnqiVFm2sCIyOTA068Y2f0TID5OAx7UFBn0+yxOiz8kO2yyHFa8GDr3Ha4KFKvczmVPyhq2DOdORoZ7YaUhJbog11Dqe6piLKyIxW7pJfExtel2tl9vQw7z7V7Gi+T7YkNBfPU4c5k/xv7poFBIKqx1UfjtMaeaH1NOqMUN4sStqL9ziIxWVrp7XqUoIW9gObArC55aZ53Us7Q85v22RVHoazStu1ARbZwNMVw75qpfOekxgiWXV/eYoPH0GOBxbcUcj7hDNr8D7KU0Tzvt37rPc4Q0H3uL58mJfdsLhvnkPijIiWWKB4P4ckNZZ1gGAFk69SHqdtzpsPrKlbU69RarqzjcN1dcah7qyaqcKL0wgZUQnMqvjoKPpv/J8CfwNbhUn10TE1zvhqqdWsjcwkPm5anoXXacrZLlIhR53UqjNMy86O7eRkaqQclNQxw+DnaFMrnckR5HgiP0w7on3vWAhOH8kh262Q2a9BzJfr5z7gfErWBfT2fuop8WE+GLkOmk4U2YtAx+djxdL1ORH62IhISinj+c1miVUaiVtI5M1E4mYyqQPwcrjlylMUYq1Rg/f9MdOKtCdYw4h5aUoZ4Fyt31y/EKu/Hbw/mk11BCd/rOrCu31T9PPB+eMXC9aD3aPfj1daddPx8iRg9/HrTg/vFvrNvUuro9iGrzzeG44hKpLst83+Jp85sHN03maoYCdTD5Z+eqrz2XJ5QDs6+48XmDuuSCEUS+sZPcEMkDzTOeI6j8B4efh67HAPt36npQi5euz5qceMWElSR5SjxifR4FYhIA9PLDzapcM4HA1TYoCxoiAvF1uAzS7QnvdkeCNKsOH5WISvP+qSt6A0Ah0xSQAa38Q66hw/2Q8Qmn1it6UTSQh7RXdsfgEgwpPSd6IsS/fi9BlGgyUnKZOl916Uv0RHoHDPs4LW8TDEoVTcTWAb3Jx2w22xrSkE3r+54twRkZzFJ5Ngwgi31aG2Bkezg9IF0achNnuGKSAYkGcd8PF/GRz8D5n83/ODARvw+B+nZG4m+ZaBD3+PjY6N/jL6rDgw1vNchjTEkG110lanT+W58GeoQX/405Z5JaJ49LKmOaxpdOpiFqebSp8KzlzjfF+uT0WbiY+J+F1E3NBfKVc+BRoN4eCnlSnprZWVjuaMNFdzZWVyq7skoFqjqQoIyNbE5gK/sHRs0Wn33gdEac13LOSVZ9wonUEn4xosbEVYkkCUppXisxTWHIHHc+Kdm1j8ngNE+kUmfQJ4X+hS2ENXbwwlKUdudvP15OX7MvaxKEJ6F4Q8/RtHpTPqpFyDlR2RSrv9FV5PJY0/9yGcGgb3e5m9JzyXBPBK3JQuVV6+3KTNlcfk0bvc1yaFS9SKob0nh3ZERQnapySn6Su5nOGnc30NBZbqO3psjPUCFvT0+KRtGW6tL+5uxOcTzLsKszF76vRXJa98i2vFsn3+cKo3Yqbiyoyz24HsZ0NpRGSRMSmuekpUrro4f0c8s6QiQCC4+YGnNEZbA7a07qaUz54J855/dTlQfhmV/2vGoV7pddIId4ycbbqn/6hg6ATR6oQyrn7SNJdL6LtenmoIExplqsjMOnlaHRfZSqH+/nbg+utLC2iBbSh2SmZlE4WJ5LCRzIDiKsAevb/gi8Uxt/PTTilvM//Jq23Iolbw0gHCX8njKrApgR9QUwEckPnGxQZMxaDPZqV2q3u1z6moPaamdiXR/FEHCYE7f7IPgMsnh0SDD6sNfmUUJlb2LyCqbegPa7eSTz3XgS6w3JWHCBtB0dNgTNRuDWHqOatRGINmbJAHUT0NBkTtVs+mnjMHBdjcGCs4KY3NUzIcC62pnboPWHtuPdQytlGgcozyq+YDhhk2V8aTrhxvxFvCIuJ3GGd4t5bWBQLn7o5yqsoBmSymdPaGm/K4to2kCAmmc8XW7dSx7dVUTudmPMJsIwPOV2VU7I6ayrncHAvYEM/Ryp8DpMv8EtHvcXGHstmVseb547CTpwqXEP7fupC44KXgogMebipVXXNbT+mwOHh8IS7kLcn62NOaWcrK9qmOzOalrMj8sA2hgdmeo6EKTzxLjQezMcVoNlqMW8Zt47lxx7gLL4gq0EcTRrPRYtwybhvPjTvGXd8LIV85XlJlJmcwsXWsnjg2gbmfexvD04dIwP/vq/cT2o3OvTs4/6GkHhJALcS3//8B5jceVjYRYcHD7Aeoh87Gylhr9A3UIPLKindVFpA/LJ2A6uetj5Ay2D4T/kt9GaSfPnUlsorqnbNWpJrcXXSmsaD6pMvBToD7Q9OnOsk/P377Koy/qt73ecykV3o2ESnOD29fUfq+f4DMA22COMTfZ3mQ3O2tUM2ar832l62tQ2cqC+o9s9R2jAjmgy5TEyLypzMdqJlgY6xAbt1nlHvyhy5B99hZzSLu7dANkPTLiBuDyTUXEEIE2qr7YkwdW3XYmPa978sWl5V1MGXz52g7f72XC6XcR3kdgJl/WrvwC7ntRLbqSZqk68NBVQSa5HUJD0yMWNmuCo4RaWP8WbtlKCVyebsCM79pF/ALuTuPbNWTNEnXJTCr+VeRIg7n6nWrsafeT14u73rAvo4rWOzpZXJ/ubd1f/UBHyU21smLm1LcAAJUu/H3O03+s5j/POHwZwAfnHdlA3x22HMx3/tXzquzEQQFBiDAL+TptRayJwerpjzwEmU+u8P8qs0rorti14W8GMLThbE3+KFBbKmsU2WOga7vI7qgtGgF2oMHeVQMtlbD5c4lTsoly6U2HGwb+Tp0t+bD7DZkm0W6/UBdL71midwtLshkFQcDX6XaF1mZI7iEe3B98zyp9LYr1cTQ/lZRQ43o6aN4JNMh7M9+VRYA7VWgbgGvRqA2E1ACcwahMFDgYvi3+v6qwZ+bSttZCwGkCEOk55uN2A5tAWuEjBHc66olRcDaJuCvBdxg61ht/J0o/UTSXv76qc2lBtre0KWHbPvMDxgt4Fs8d5j5sSPv2QVFe22mAn9RlGC4WT/BYMmmNi15U1E3Cdceeafutj/7t80ULafwrxMljsZu1poH4jrrAQhaPrhZXEuOBvlUUstEom4jct4R8u0xIA2YgpggaG+I4OrLg/jtFtLhoF3kFyOuwTS0FoSWp8uqk6ycdyTyZyWB6CYd0xLeTzWT/8479jWRpaA9UH55VcRpaC0QmC5sMI+GmQbWdoHqXCbuAX5r8P0JYfb3OIx67nlGM/wGc6YwUDoGM4SCQFDtZMctIinJMNuManC3mNW7nN/nIAGuThhIvqQHk/sTcA8YmfY1gdswbsEA4wFNQbQJI5h0EIC4HWK//4FUROYTsuVifzmUQwJMSYWUnTQV9MKX5UrJvCuVpHviOqlkMOVI61AkuY7up0ts+gS6AybfAeNVrD+RYvB3OPvhWzMTKQjIPJIhGSBAam4iN6Odz4cMXgB4JJ7TxRC8T4thPL1ZDCdzfDECzQ+LkSjmSZRRuwgrQtRNQABMT7QYAmjMinDqFKRZG2Qd5wz0m0MLDdhVnRqFalk1K1StQrEkdRrVqNqddYmuy5suzboMKHVzCa7XREWq63yhVK1JXteqREEd3VRZQsVllLqZdOqOWiWmD9tUCZds5ArXbDCFfamMXHgieDoquJVFQc3CUc6nhdqD1XREx4bgN3RojI78axlpRdNpkyxt5ZJ1amRdqXCxnafTwgjGiMF1z2fHK1NhbNmiiEQx51JKZDHOdNOsqhXqCpkVK3O6tOiq7haH5lYzmw8FU9+jfqbwAjMPkILgQzK8N72gecPwgeXLDw4eAREJmb8AgSioaOgYggQLwcTCxsEd4KM/ASERMQkp89aAHzoic9RUq7OOzQfG0dLRMwzho8wsysrm2SUOZYOT/lOkSpPOxS1joBCdnXVuXj5fgcIgwNA/U8es8kyXxRZaa4sNQYIFQYHZlscD8gTd8QLznHEnaLDOVn/50yc/2GHUz3YqUqxHiV+V+sWYCeddMO65Sa646JJdyryz1HVXXVPupdfmq1ShSo1qtdar06BeoyYtmrVq80K7yTpMMVWnw7433TQzzPTKG0fiDWHAbnvcjA+45bbf7LXPQYectd8BI+baFizkC4474Wj8LBrOYPBIkfMvoMAoUaN1/PXZFVSwk04lJKYbYcWOEzde/EITJEyUOEnSZMlTFJay8CKKLKroVKmLKTZNcWnTpc+QMVPmLMVn9fahc9/LhV6/hk0eEbIkTXozHqyZbo5tdlavBBiiIe+jsxcXazZ9WD3O/l/ZGjJ0BwNsxwe3mXbYv8cAvvZsgaiSMGRDrdLGEMHQfauZu+1uF76cDWYTYbVusZqHiMlA1W3/9WlRIpfAUEp0xjd8jjJGNI2njjlRHf5dXstSf4C596DqDnk0PHYkO8PbZlOTiVrThCrq6fbgKGiob3yYkEc5j6nwVibOSNITSgRPJEkeS7onSTYoCSQJPJHmA0qCKfG2KAh6QhBMkCTQPYFAIBsCwQQ9EAjEzc4i0cZBabG6dWw5Tf08s45SN2rhSk3zZuZifXvwVj/YX9D/meMX) format("woff2"); + } + + :root { + --jf-background: #101010; + --jf-paper: #202020; + --jf-text: #fff; + --jf-text-secondary: rgba(255, 255, 255, 0.7); + --jf-primary: #00a4dc; + --jf-primary-dark: #00729a; + --jf-divider: rgba(255, 255, 255, 0.12); + --jf-error: #d15353; + --jf-warn: #f2b01e; + --jf-success: #5cbd5c; + --jf-scrollbar-thumb: #3b3b3b; + } + * { - font-family: sans-serif; + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--jf-scrollbar-thumb) var(--jf-paper); + } + + html { + height: 100%; + font-size: 93%; + line-height: 1.35; } - .flex-row { + body { + margin: 0; + padding: 0 0 50px; + min-height: 100%; + background-color: var(--jf-background); + color: var(--jf-text); + font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC", "Noto Sans TC", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + ::-webkit-scrollbar { + width: 0.4em; + height: 0.4em; + } + + ::-webkit-scrollbar-track { + background: var(--jf-paper); + } + + ::-webkit-scrollbar-thumb { + background: #888; + border-radius: 2px; + } + + a { + color: var(--jf-primary); + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + .page { + max-width: 54em; + margin: 0 auto; + padding: 4.5em 3.3% 0; + } + + .logo { + display: block; + width: 15em; + max-width: 70%; + height: auto; + margin: 0 auto 2.25em; + } + + .status { display: flex; flex-direction: row; - flex-wrap: nowrap; + align-items: center; justify-content: center; + gap: 0.75em; + text-align: center; + margin-bottom: 1.5em; + } + + .status .status-text { + margin: 0; + font-weight: 400; + font-size: 1.25em; + color: var(--jf-text); + } + + .status.is-error .status-text { + color: var(--jf-error); + } + + /* Buttons — matching the web client's emby-button styles. */ + .jf-button { + display: inline-flex; align-items: center; - align-content: normal; + justify-content: center; + box-sizing: border-box; + margin: 0; + padding: 0.9em 1em; + border: 0; + border-radius: 0.2em; + font-family: inherit; + font-size: inherit; + font-weight: 600; + line-height: 1.35; + cursor: pointer; + outline: none; + text-decoration: none; + transition: 0.2s; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + .jf-button-primary { + background: var(--jf-primary); + color: rgba(0, 0, 0, 0.87); + } + + .jf-button-primary:hover, + .jf-button-primary:focus { + background: var(--jf-primary-dark); + } + + .jf-button-secondary { + background: #424242; + color: var(--jf-text-secondary); + } + + .jf-button-secondary:hover, + .jf-button-secondary:focus { + background: #616161; } - .flex-col { + /* Redirect countdown shown once the server is ready. */ + .redirect-bar { display: flex; - flex-direction: column; - flex-wrap: nowrap; - justify-content: center; + flex-direction: row; align-items: center; - align-content: normal; + justify-content: center; + flex-wrap: wrap; + gap: 1em; + margin-bottom: 1.5em; + text-align: center; + } + + .redirect-countdown { + color: var(--jf-text-secondary); + } + + /* Material (MDL) spinner — the same one the web client uses while loading. */ + .mdl-spinner { + position: relative; + display: inline-block; + flex: 0 0 auto; + width: 1.95em; + height: 1.95em; + font-size: 0.8em; + animation: mdl-spinner__container-rotate 1568.23529412ms linear infinite; + } + + @keyframes mdl-spinner__container-rotate { + to { + transform: rotate(360deg); + } } - header { - height: 5rem; + .mdl-spinner__layer { + position: absolute; width: 100%; + height: 100%; + opacity: 0; + border-color: var(--jf-primary); } - header svg { - height: 3rem; - width: 9rem; - margin-right: 1rem; + .mdl-spinner__layer-1 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-1-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - /* ol.action-list { - list-style-type: none; - position: relative; - } */ + .mdl-spinner__layer-2 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-2-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; + } - ol.action-list * { - font-family: monospace; - font-weight: 300; - font-size: clamp(18px, 100vw / var(--width), 20px); - font-feature-settings: 'onum', 'pnum'; - line-height: 1.8; - -webkit-text-size-adjust: none; + .mdl-spinner__layer-3 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-3-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - /* - ol.action-list li { - padding-top: .5rem; + .mdl-spinner__layer-4 { + animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-4-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - ol.action-list li::before { - position: absolute; - left: -0.8em; - font-size: 1.1em; - } */ + @keyframes mdl-spinner__fill-unfill-rotate { + 12.5% { transform: rotate(135deg); } + 25% { transform: rotate(270deg); } + 37.5% { transform: rotate(405deg); } + 50% { transform: rotate(540deg); } + 62.5% { transform: rotate(675deg); } + 75% { transform: rotate(810deg); } + 87.5% { transform: rotate(945deg); } + to { transform: rotate(1080deg); } + } - /* Attribution as heavily inspired by: https://iamkate.com/code/tree-views/ */ - .action-list { - --spacing: 1.4rem; - --radius: 14px; + @keyframes mdl-spinner__layer-1-fade-in-out { + from { opacity: 0.99; } + 25% { opacity: 0.99; } + 26% { opacity: 0; } + 89% { opacity: 0; } + 90% { opacity: 0.99; } + 100% { opacity: 0.99; } } - .action-list li { - display: block; + @keyframes mdl-spinner__layer-2-fade-in-out { + from { opacity: 0; } + 15% { opacity: 0; } + 25% { opacity: 0.99; } + 50% { opacity: 0.99; } + 51% { opacity: 0; } + } + + @keyframes mdl-spinner__layer-3-fade-in-out { + from { opacity: 0; } + 40% { opacity: 0; } + 50% { opacity: 0.99; } + 75% { opacity: 0.99; } + 76% { opacity: 0; } + } + + @keyframes mdl-spinner__layer-4-fade-in-out { + from { opacity: 0; } + 65% { opacity: 0; } + 75% { opacity: 0.99; } + 90% { opacity: 0.99; } + 100% { opacity: 0; } + } + + .mdl-spinner__circle { + box-sizing: border-box; + height: 100%; + border-width: 0.21em; + border-style: solid; + border-color: inherit; + border-bottom-color: transparent !important; + border-radius: 50%; + animation: none; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + .mdl-spinner__circle-clipper { + display: inline-block; position: relative; - padding-left: calc(2 * var(--spacing) - var(--radius) - 1px); + width: 50%; + height: 100%; + overflow: hidden; + border-color: inherit; } - .action-list ul { - margin-left: calc(var(--radius) - var(--spacing)); - padding-left: 0; + .mdl-spinner__circle-clipper .mdl-spinner__circle { + width: 200%; } - .action-list ul li { - border-left: 2px solid #ddd; + .mdl-spinner__circleLeft { + border-right-color: transparent !important; + transform: rotate(129deg); + animation: mdl-spinner__left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - .action-list ul li:last-child { - border-color: transparent; + .mdl-spinner__circleRight { + left: -100%; + border-left-color: transparent !important; + transform: rotate(-129deg); + animation: mdl-spinner__right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; } - .action-list ul li::before { - content: ''; - display: block; - position: absolute; - top: calc(var(--spacing) / -2); - left: -2px; - width: calc(var(--spacing) + 2px); - height: calc(var(--spacing) + 1px); - border: solid #ddd; - border-width: 0 0 2px 2px; + @keyframes mdl-spinner__left-spin { + from { transform: rotate(130deg); } + 50% { transform: rotate(-5deg); } + to { transform: rotate(130deg); } } - .action-list summary { - display: block; - cursor: pointer; + @keyframes mdl-spinner__right-spin { + from { transform: rotate(-130deg); } + 50% { transform: rotate(5deg); } + to { transform: rotate(-130deg); } } - .action-list summary::marker, - .action-list summary::-webkit-details-marker { - display: none; + /* Startup log — a paperList-style surface. */ + .logs-panel { + background-color: var(--jf-paper); + border-radius: 0.2em; + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + padding: calc(0.8em - 4px) 1.2em 1em; } - .action-list summary:focus { - outline: none; + .logs-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1em; + margin-bottom: 0.5em; + padding-bottom: 0.5em; + border-bottom: 1px solid var(--jf-divider); } - .action-list summary:focus-visible { - outline: 1px dotted #000; + .logs-header h2 { + margin: 0; + font-weight: 400; + font-size: 1.17em; } - .action-list li::after, - .action-list summary::before { - content: ''; - display: block; - position: absolute; - top: calc(var(--spacing) / 2 - var(--radius) + 4px); - left: calc(var(--spacing) - var(--radius) - -5px); + .download-logs { + flex: 0 0 auto; + font-size: 0.9em; + font-weight: 600; } - .action-list summary::before { - z-index: 1; - /* background: #696 url('expand-collapse.svg') 0 0; */ + .logs-scroll { + overflow-y: auto; } - .action-list details[open]>summary::before { - background-position: calc(-2 * var(--radius)) 0; + .action-list, + .action-list ul { + list-style: none; + margin: 0; + padding: 0; } - .action-list li.danger-item::after, - .action-list li.danger-strong-item::after { - content: '❌'; + .action-list { + font-size: 0.92em; } - ol.action-list li span.danger-strong-item { - text-decoration-style: solid; - text-decoration-color: red; - text-decoration-line: underline; + .action-list li { + position: relative; + padding: 0.18em 0; } - ol.action-list li.warn-item::after { - content: '⚠️'; + .action-list summary, + .action-list li > span { + display: block; + color: var(--jf-text); } - ol.action-list li.success-item::after { - content: '✅'; + .action-list summary { + cursor: pointer; + list-style: none; + } + + .action-list summary::-webkit-details-marker { + display: none; } - ol.action-list li.info-item::after { - content: '🔹'; + /* Severity dot, colored from the theme palette. */ + .action-list summary::before, + .action-list li > span::before { + content: ""; + display: inline-block; + flex: 0 0 auto; + width: 0.5em; + height: 0.5em; + margin-right: 0.7em; + border-radius: 50%; + background: var(--dot, var(--jf-text-secondary)); } - /* End Attribution */ + .action-list li.success-item { --dot: var(--jf-success); } + .action-list li.info-item { --dot: var(--jf-primary); } + .action-list li.warn-item { --dot: var(--jf-warn); } + .action-list li.danger-item, + .action-list li.danger-strong-item { --dot: var(--jf-error); } + + .action-list li.warn-item > span, + .action-list li.warn-item > details > summary { color: var(--jf-warn); } + + .action-list li.danger-item > span, + .action-list li.danger-item > details > summary, + .action-list li.danger-strong-item > span, + .action-list li.danger-strong-item > details > summary { color: var(--jf-error); } + + .action-list li.danger-strong-item > span { font-weight: 700; } + + /* Nested groups: indent with a divider guide line. */ + .action-list ul { + margin-left: 0.25em; + padding-left: 1.1em; + border-left: 1px solid var(--jf-divider); + } </style> </head> <body> - <div> - <header class="flex-row"> - - {{^IF isInReportingMode}} - <p>Jellyfin Server {{version}} still starting. Please wait.</p> - {{#ELSE}} - <p>Jellyfin Server has encountered an error and was not able to start.</p> - {{/ELSE}} - {{/IF}} - - {{#IF localNetworkRequest}} - <p style="margin-left: 1rem;">You can download the current log file <a href='/startup/logger' - target="_blank">here</a>.</p> - {{/IF}} - </header> + <div class="page"> + <svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 251 72" fill="none" role="img" aria-label="Jellyfin"> + <g clip-path="url(#a)"> + <path fill="url(#b)" d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" /> + <path fill="url(#c)" fill-rule="evenodd" d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847" clip-rule="evenodd" /> + <path fill="#fff" d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" /> + </g> + <defs> + <linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002" gradientUnits="userSpaceOnUse"> + <stop stop-color="#aa5cc3" /> + <stop offset="1" stop-color="#00a4dc" /> + </linearGradient> + <linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001" gradientUnits="userSpaceOnUse"> + <stop stop-color="#aa5cc3" /> + <stop offset="1" stop-color="#00a4dc" /> + </linearGradient> + <clipPath id="a"> + <path fill="#fff" d="M0 0h251v72H0z" /> + </clipPath> + </defs> + </svg> + + {{^IF isInReportingMode}} + <div class="status"> + <div class="mdl-spinner" dir="ltr" aria-hidden="true"> + <div class="mdl-spinner__layer mdl-spinner__layer-1"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + <div class="mdl-spinner__layer mdl-spinner__layer-2"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + <div class="mdl-spinner__layer mdl-spinner__layer-3"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + <div class="mdl-spinner__layer mdl-spinner__layer-4"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div> + </div> + <p class="status-text">Jellyfin is still starting. Please wait… {{currentActivity}}</p> + </div> + {{#ELSE}} + <div class="status is-error"> + <p class="status-text">Jellyfin has encountered an error and was not able to start.</p> + </div> + {{/ELSE}} + {{/IF}} {{#DECLARE LogEntry |--}} {{#LET children = Children}} @@ -192,7 +472,7 @@ <details open> <summary>{{DateOfCreation}} - {{Content}}</summary> <ul class="action-list"> - {{--| #EACH children.Reverse() |-}} + {{--| #EACH children |-}} {{#IMPORT 'LogEntry'}} {{--| /EACH |-}} </ul> @@ -205,31 +485,175 @@ {{--| /DECLARE}} {{#IF localNetworkRequest}} - <div class="flex-col"> - <ol class="action-list"> - {{#FOREACH log IN logs.Reverse()}} - {{#IMPORT 'LogEntry' #WITH log}} - {{/FOREACH}} - </ol> + <div class="logs-panel"> + <div class="logs-header"> + <h2>Startup log</h2> + <a class="download-logs" href='/startup/logger' target="_blank">Download logs</a> + </div> + <div class="logs-scroll" id="logs-scroll"> + <ol class="action-list"> + {{#FOREACH log IN logs}} + {{#IMPORT 'LogEntry' #WITH log}} + {{/FOREACH}} + </ol> + </div> </div> - {{#ELSE}} - {{#IF networkManagerReady}} - <p>Please visit this page from your local network to view detailed startup logs.</p> - {{#ELSE}} - <p>Initializing network settings. Please wait.</p> - {{/ELSE}} - {{/IF}} - {{/ELSE}} {{/IF}} </div> + <script> + (function () { + var reporting = false; + {{#IF isInReportingMode}} + reporting = true; + {{/IF}} + var intervalMs = {{ retryValue.TotalMilliseconds }}; + + var box = document.getElementById('logs-scroll'); + var panel = box ? box.closest('.logs-panel') : null; + + function px(el, prop) { + return el ? (parseFloat(getComputedStyle(el)[prop]) || 0) : 0; + } + + // Size the log viewport so the panel never runs past the bottom of the window + // (keeping the body's bottom padding as the margin). Recomputed on resize. + function fit() { + if (!box) { + return; + } + var topFromDoc = box.getBoundingClientRect().top + window.scrollY; + var below = px(panel, 'paddingBottom') + px(document.body, 'paddingBottom'); + box.style.maxHeight = Math.max(window.innerHeight - topFromDoc - below, 120) + 'px'; + } + + function nearBottom(el) { + return el.scrollHeight - el.scrollTop - el.clientHeight < 24; + } + + fit(); + window.addEventListener('resize', fit); + window.addEventListener('load', fit); + if (box) { + box.scrollTop = box.scrollHeight; // start pinned to the newest entry + } + + // In the terminal error state the page is static, so stop here. + if (reporting) { + return; + } + + // Soft refresh: pull the page in the background and swap the log list + activity line in + // place, so polling never disturbs where the user has scrolled. Only follow to the bottom + // when the user is already there. A real reload happens only on the final transition. + function poll() { + fetch(window.location.href, { cache: 'no-store' }).then(function (resp) { + if (resp.ok) { + // The real server is now answering (HTTP 200) -> offer to continue to the app. + onServerReady(); + return null; + } + return resp.text(); + }).then(function (html) { + if (!html) { + return; + } + var doc = new DOMParser().parseFromString(html, 'text/html'); + + // Startup failed and the page switched to the error view -> reload to render it. + if (doc.querySelector('.status.is-error')) { + window.location.reload(); + return; + } + + var newStatus = doc.querySelector('.status-text'); + var curStatus = document.querySelector('.status-text'); + if (newStatus && curStatus) { + curStatus.innerHTML = newStatus.innerHTML; + } + + if (box) { + var newList = doc.querySelector('#logs-scroll .action-list'); + var curList = box.querySelector('.action-list'); + if (newList && curList) { + var stick = nearBottom(box); + var prevTop = box.scrollTop; + curList.replaceWith(document.importNode(newList, true)); + fit(); + box.scrollTop = stick ? box.scrollHeight : prevTop; + } + } + }).catch(function () { + // Server is mid-transition (port rebinding); just try again on the next tick. + }); + } + + // The server finished starting. Stop polling and present a cancelable countdown so the + // user can either ride the redirect into the app or stay to review the startup output. + function onServerReady() { + clearInterval(pollTimer); + + var status = document.querySelector('.status'); + var statusText = document.querySelector('.status-text'); + var spinner = document.querySelector('.mdl-spinner'); + if (spinner) { + spinner.style.display = 'none'; + } + if (status) { + status.classList.add('is-success'); + } + if (statusText) { + statusText.textContent = 'Jellyfin started successfully.'; + } + if (!status) { + window.location.reload(); + return; + } + + var bar = document.createElement('div'); + bar.className = 'redirect-bar'; + var countdownText = document.createElement('span'); + countdownText.className = 'redirect-countdown'; + var cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'jf-button jf-button-secondary'; + cancelButton.textContent = 'Cancel'; + bar.appendChild(countdownText); + bar.appendChild(cancelButton); + status.insertAdjacentElement('afterend', bar); + + var remaining = 5; + function renderCountdown() { + countdownText.textContent = 'Redirecting in ' + remaining + '…'; + } + renderCountdown(); + var countdown = setInterval(function () { + remaining -= 1; + if (remaining <= 0) { + clearInterval(countdown); + window.location.reload(); + return; + } + renderCountdown(); + }, 1000); + + // Cancel stops both the redirect and the refreshing, and offers a manual continue. + cancelButton.addEventListener('click', function () { + clearInterval(countdown); + bar.innerHTML = ''; + var continueButton = document.createElement('button'); + continueButton.type = 'button'; + continueButton.className = 'jf-button jf-button-primary'; + continueButton.textContent = 'Continue to Jellyfin'; + continueButton.addEventListener('click', function () { + window.location.reload(); + }); + bar.appendChild(continueButton); + }); + } + + var pollTimer = setInterval(poll, intervalMs); + })(); + </script> </body> -{{^IF isInReportingMode}} -<script> - setTimeout(() => { - window.location.reload(); - }, {{ retryValue.TotalMilliseconds }}); -</script> -{{/IF}} - </html> diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs index 96af423cc3..4723be1001 100644 --- a/MediaBrowser.Common/Plugins/LocalPlugin.cs +++ b/MediaBrowser.Common/Plugins/LocalPlugin.cs @@ -109,7 +109,7 @@ namespace MediaBrowser.Common.Plugins { var inst = Instance?.GetPluginInfo() ?? new PluginInfo(Manifest.Name, Version, Manifest.Description, Manifest.Id, true); inst.Status = Manifest.Status; - inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath); + inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath) || !string.IsNullOrEmpty(Manifest.ImageResourceName); return inst; } diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs index e0847ccea4..e749e85899 100644 --- a/MediaBrowser.Common/Plugins/PluginManifest.cs +++ b/MediaBrowser.Common/Plugins/PluginManifest.cs @@ -108,6 +108,15 @@ namespace MediaBrowser.Common.Plugins public string? ImagePath { get; set; } /// <summary> + /// Gets or sets the name of an embedded resource in the plugin's assembly + /// that should be served as the plugin image. + /// Used by bundled/integrated plugins whose images are shipped inside the assembly + /// rather than on disk. Ignored when <see cref="ImagePath"/> is set. + /// </summary> + [JsonIgnore] + public string? ImageResourceName { get; set; } + + /// <summary> /// Gets or sets the collection of assemblies that should be loaded. /// Paths are considered relative to the plugin folder. /// </summary> diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs index ca7721991d..9557c91964 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs @@ -1,19 +1,29 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The result of a channel item query. + /// </summary> public class ChannelItemResult { + /// <summary> + /// Initializes a new instance of the <see cref="ChannelItemResult"/> class. + /// </summary> public ChannelItemResult() { Items = Array.Empty<ChannelItemInfo>(); } + /// <summary> + /// Gets or sets the items. + /// </summary> public IReadOnlyList<ChannelItemInfo> Items { get; set; } + /// <summary> + /// Gets or sets the total record count. + /// </summary> public int? TotalRecordCount { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelItemType.cs b/MediaBrowser.Controller/Channels/ChannelItemType.cs index 3ce920e236..2608cb4c88 100644 --- a/MediaBrowser.Controller/Channels/ChannelItemType.cs +++ b/MediaBrowser.Controller/Channels/ChannelItemType.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The type of a channel item. + /// </summary> public enum ChannelItemType { + /// <summary> + /// The item is a media item. + /// </summary> Media = 0, + /// <summary> + /// The item is a folder. + /// </summary> Folder = 1 } } diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs index ebbe13763b..c6530814b9 100644 --- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs +++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs @@ -1,11 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The request for a latest media search in a channel. + /// </summary> public class ChannelLatestMediaSearch { + /// <summary> + /// Gets or sets the user id. + /// </summary> public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs index f77d81c166..a5a1ba5bf6 100644 --- a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs +++ b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs @@ -1,17 +1,33 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The parental rating of a channel. + /// </summary> public enum ChannelParentalRating { + /// <summary> + /// Suitable for a general audience. + /// </summary> GeneralAudience = 0, + /// <summary> + /// Parental guidance suggested (US PG). + /// </summary> UsPG = 1, + /// <summary> + /// Parents strongly cautioned (US PG-13). + /// </summary> UsPG13 = 2, + /// <summary> + /// Restricted (US R). + /// </summary> UsR = 3, + /// <summary> + /// Suitable for adults only. + /// </summary> Adult = 4 } } diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs index 990b025bcb..d172b98b25 100644 --- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs +++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs @@ -1,13 +1,20 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// The request for a search in a channel. + /// </summary> public class ChannelSearchInfo { + /// <summary> + /// Gets or sets the search term. + /// </summary> public string SearchTerm { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> public string UserId { get; set; } } } diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs index 7d5207c34a..4cdda38bd9 100644 --- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs +++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs @@ -1,14 +1,15 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Channels { + /// <summary> + /// Interface for channels that provide a cache key. + /// </summary> public interface IHasCacheKey { /// <summary> /// Gets the cache key. /// </summary> /// <param name="userId">The user identifier.</param> - /// <returns>System.String.</returns> + /// <returns>The cache key.</returns> string? GetCacheKey(string? userId); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs index 0110bfa7a3..194654ca9e 100644 --- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs +++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs @@ -1,15 +1,27 @@ -#pragma warning disable CS1591 - using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Channels { + /// <summary> + /// Interface for channels that support deleting items. + /// </summary> public interface ISupportsDelete { + /// <summary> + /// Gets a value indicating whether the item can be deleted. + /// </summary> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if the item can be deleted, <c>false</c> otherwise.</returns> bool CanDelete(BaseItem item); + /// <summary> + /// Deletes the item with the provided id. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the deletion of the item.</returns> Task DeleteItem(string id, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs index 1935ec0f5f..82ca45d3ad 100644 --- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs +++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs @@ -1,11 +1,12 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Controller.Channels { + /// <summary> + /// Interface for channels that support retrieving the latest media. + /// </summary> public interface ISupportsLatestMedia { /// <summary> diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index 206b5ac426..8d5d54ffd9 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -58,6 +58,14 @@ namespace MediaBrowser.Controller.Collections IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user); /// <summary> + /// Gets the collections accessible to the supplied user that contain the provided item. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="itemId">The item identifier.</param> + /// <returns>The collections containing the item.</returns> + IEnumerable<BoxSet> GetCollectionsContainingItem(User user, Guid itemId); + + /// <summary> /// Gets the folder where collections are stored. /// </summary> /// <param name="createIfNeeded">Will create the collection folder on the storage if set to true.</param> diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index a71cdbd62c..052626355f 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -8,13 +6,16 @@ using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Dto { + /// <summary> + /// Options that control which fields and images are populated when building a <see cref="MediaBrowser.Model.Dto.BaseItemDto"/>. + /// </summary> public class DtoOptions { - private static readonly ItemFields[] DefaultExcludedFields = new[] - { + private static readonly ItemFields[] DefaultExcludedFields = + [ ItemFields.SeasonUserData, ItemFields.RefreshState - }; + ]; private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>(); @@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto .Except(DefaultExcludedFields) .ToArray(); + /// <summary> + /// Initializes a new instance of the <see cref="DtoOptions"/> class with all fields enabled. + /// </summary> public DtoOptions() : this(true) { } + /// <summary> + /// Initializes a new instance of the <see cref="DtoOptions"/> class. + /// </summary> + /// <param name="allFields">Whether to populate all available fields.</param> public DtoOptions(bool allFields) { ImageTypeLimit = int.MaxValue; @@ -38,23 +46,54 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + /// <summary> + /// Gets or sets the fields to populate on the DTO. + /// </summary> public IReadOnlyList<ItemFields> Fields { get; set; } + /// <summary> + /// Gets or sets the image types to populate on the DTO. + /// </summary> public IReadOnlyList<ImageType> ImageTypes { get; set; } + /// <summary> + /// Gets or sets the maximum number of images to return per image type. + /// </summary> public int ImageTypeLimit { get; set; } + /// <summary> + /// Gets or sets a value indicating whether image information is populated. + /// </summary> public bool EnableImages { get; set; } + /// <summary> + /// Gets or sets a value indicating whether program recording information is populated. + /// </summary> public bool AddProgramRecordingInfo { get; set; } + /// <summary> + /// Gets or sets a value indicating whether user data is populated. + /// </summary> public bool EnableUserData { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the currently airing program is populated. + /// </summary> public bool AddCurrentProgram { get; set; } + /// <summary> + /// Gets a value indicating whether the specified field is populated. + /// </summary> + /// <param name="field">The field to check.</param> + /// <returns><c>true</c> if the field is populated; otherwise, <c>false</c>.</returns> public bool ContainsField(ItemFields field) => Fields.Contains(field); + /// <summary> + /// Gets the number of images to return for the specified image type. + /// </summary> + /// <param name="type">The image type.</param> + /// <returns>The image limit for the type, or 0 if the type is not enabled.</returns> public int GetImageLimit(ImageType type) { if (EnableImages && ImageTypes.Contains(type)) diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 4cdcaabbb1..49a4ed4bf6 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -23,7 +23,6 @@ using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; @@ -88,12 +87,16 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; + private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private string _sortName; private string _forcedSortName; private string _name; + private string _originalLanguage; + public const char SlugChar = '-'; protected BaseItem() @@ -217,7 +220,11 @@ namespace MediaBrowser.Controller.Entities public string OriginalTitle { get; set; } [JsonIgnore] - public string OriginalLanguage { get; set; } + public string OriginalLanguage + { + get => _originalLanguage; + set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value; + } /// <summary> /// Gets or sets the id. @@ -1094,8 +1101,9 @@ namespace MediaBrowser.Controller.Entities } } - var list = GetAllItemsForMediaSources(); - var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList(); + var list = GetAllItemsForMediaSources().ToList(); + var commonPrefix = GetCommonNamePrefix(list); + var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList(); if (IsActiveRecording()) { @@ -1105,17 +1113,15 @@ namespace MediaBrowser.Controller.Entities } } - return result.OrderBy(i => - { - if (i.VideoType == VideoType.VideoFile) - { - return 0; - } + // The source belonging to the item being queried sorts first so it is the default the client plays. + var selfId = Id.ToString("N", CultureInfo.InvariantCulture); - return 1; - }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) - .ThenByDescending(i => i, new MediaSourceWidthComparator()) - .ToArray(); + return result + .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase)) + .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1) + .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) + .ThenByDescending(i => i, new MediaSourceWidthComparator()) + .ToArray(); } protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() @@ -1123,20 +1129,12 @@ namespace MediaBrowser.Controller.Entities return Enumerable.Empty<(BaseItem, MediaSourceType)>(); } - private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type) + private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null) { ArgumentNullException.ThrowIfNull(item); var protocol = item.PathProtocol; - - // Resolve the item path so everywhere we use the media source it will always point to - // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link - // path will return null, so it's safe to check for all paths. var itemPath = item.Path; - if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo) - { - itemPath = linkInfo.FullName; - } var info = new MediaSourceInfo { @@ -1144,7 +1142,7 @@ namespace MediaBrowser.Controller.Entities Protocol = protocol ?? MediaProtocol.File, MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), - Name = GetMediaSourceName(item), + Name = GetMediaSourceName(item, commonPrefix), Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath, RunTimeTicks = item.RunTimeTicks, Container = item.Container, @@ -1223,7 +1221,7 @@ namespace MediaBrowser.Controller.Entities return info; } - internal string GetMediaSourceName(BaseItem item) + internal string GetMediaSourceName(BaseItem item, string commonPrefix = null) { var terms = new List<string>(); @@ -1231,12 +1229,31 @@ namespace MediaBrowser.Controller.Entities if (item.IsFileProtocol && !string.IsNullOrEmpty(path)) { var displayName = System.IO.Path.GetFileNameWithoutExtension(path); - if (HasLocalAlternateVersions) + + // Prefer the suffix that differs from the other versions: strip the prefix shared by + // all sibling files. This works regardless of folder layout, so it also labels episode + // versions that share a season folder (e.g. "Greyscale" instead of the full + // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter + // boundary (see GetCommonVersionPrefix). + if (!string.IsNullOrEmpty(commonPrefix) + && displayName.Length > commonPrefix.Length + && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase)) + { + var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]); + if (!name.IsWhiteSpace()) + { + terms.Add(name.ToString()); + } + } + + // Fall back to the containing folder name (the common layout for movie versions, and + // the path taken when no common prefix could be derived). + if (terms.Count == 0 && HasLocalAlternateVersions) { var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath); if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase)) { - var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']); + var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]); if (!name.IsWhiteSpace()) { terms.Add(name.ToString()); @@ -1293,6 +1310,98 @@ namespace MediaBrowser.Controller.Entities return string.Join('/', terms); } + /// <summary> + /// Derives the prefix shared by the supplied media source items' file names, used to strip the + /// common part and surface a short version label per source. Returns null when there are fewer + /// than two file-based sources, since there is nothing to differentiate. + /// </summary> + /// <param name="items">The media source items.</param> + /// <returns>The shared prefix, or null when no useful prefix exists.</returns> + private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items) + { + var fileNames = new List<string>(); + foreach (var (item, _) in items) + { + if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path)) + { + fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path)); + } + } + + if (fileNames.Count < 2) + { + return null; + } + + var prefix = GetCommonVersionPrefix(fileNames); + return string.IsNullOrEmpty(prefix) ? null : prefix; + } + + /// <summary> + /// Computes the case-insensitive longest common prefix of the supplied version file names, + /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact: + /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and + /// "Greyish") while still trimming the common part when every version is suffixed (e.g. + /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a + /// 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). + /// </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> + internal static string GetCommonVersionPrefix(IReadOnlyList<string> fileNames) + { + var prefix = fileNames[0]; + for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++) + { + var name = fileNames[i]; + var length = Math.Min(prefix.Length, name.Length); + var common = 0; + while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common])) + { + common++; + } + + prefix = prefix[..common]; + } + + // If the common prefix is itself a whole file name then one version is unlabelled (the + // base name); the boundary already sits at the end of that name, so don't retreat into it. + var prefixIsWholeName = false; + for (var i = 0; i < fileNames.Count; i++) + { + if (fileNames[i].Length == prefix.Length) + { + prefixIsWholeName = true; + break; + } + } + + if (!prefixIsWholeName) + { + // Retreat to the last structural delimiter ('-', '_', '.'). + var cut = prefix.Length; + while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0) + { + cut--; + } + + if (cut == 0) + { + cut = prefix.Length; + while (cut > 0 && prefix[cut - 1] != ' ') + { + cut--; + } + } + + prefix = prefix[..cut]; + } + + return prefix; + } + public Task RefreshMetadata(CancellationToken cancellationToken) { return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); @@ -1564,7 +1673,7 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Gets the preferred metadata language. + /// Gets the preferred metadata country code. /// </summary> /// <returns>System.String.</returns> public string GetPreferredMetadataCountryCode() @@ -1598,6 +1707,15 @@ namespace MediaBrowser.Controller.Entities return lang; } + /// <summary> + /// Gets the original language of the item, inheriting from parent items if necessary. + /// </summary> + /// <returns>System.String.</returns> + public virtual string GetInheritedOriginalLanguage() + { + return OriginalLanguage; + } + public virtual bool IsSaveLocalMetadataEnabled() { if (SourceType == SourceType.Channel) @@ -2005,12 +2123,23 @@ namespace MediaBrowser.Controller.Entities // I think it is okay to do this here. // if this is only called when a user is manually forcing something to un-played // then it probably is what we want to do... + ResetPlayedState(data); + + UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } + + /// <summary> + /// Clears the played state on the supplied user data. + /// </summary> + /// <param name="data">The user data to reset.</param> + protected static void ResetPlayedState(UserItemData data) + { + ArgumentNullException.ThrowIfNull(data); + data.PlayCount = 0; data.PlaybackPositionTicks = 0; data.LastPlayedDate = null; data.Played = false; - - UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); } /// <summary> @@ -2712,7 +2841,7 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) { - return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray(); + return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray(); } public IReadOnlyList<BaseItem> GetThemeVideos(User user = null) @@ -2722,18 +2851,28 @@ namespace MediaBrowser.Controller.Entities public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy) { - return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); + return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray(); + } + + /// <summary> + /// Gets the ids of the items whose owned extras belong to this item. + /// </summary> + /// <returns>An array containing the owner ids.</returns> + protected virtual Guid[] GetExtraOwnerIds() + { + 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> /// <returns>An enumerable containing the items.</returns> - public IEnumerable<BaseItem> GetExtras() + public IEnumerable<BaseItem> GetExtras(User user = null) { - return LibraryManager.GetItemList(new InternalItemsQuery() + return LibraryManager.GetItemList(new InternalItemsQuery(user) { - OwnerIds = [Id], + OwnerIds = GetExtraOwnerIds(), OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] }); } @@ -2742,12 +2881,13 @@ namespace MediaBrowser.Controller.Entities /// Get all extras with specific types that are associated with this item. /// </summary> /// <param name="extraTypes">The types of extras to retrieve.</param> + /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> /// <returns>An enumerable containing the extras.</returns> - public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes) + public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes, User user = null) { - return LibraryManager.GetItemList(new InternalItemsQuery() + return LibraryManager.GetItemList(new InternalItemsQuery(user) { - OwnerIds = [Id], + OwnerIds = GetExtraOwnerIds(), ExtraTypes = extraTypes.ToArray(), OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] }); 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/Extensions.cs b/MediaBrowser.Controller/Entities/Extensions.cs index c56603a3eb..380041af84 100644 --- a/MediaBrowser.Controller/Entities/Extensions.cs +++ b/MediaBrowser.Controller/Entities/Extensions.cs @@ -34,7 +34,7 @@ namespace MediaBrowser.Controller.Entities } else { - item.RemoteTrailers = [..item.RemoteTrailers, mediaUrl]; + item.RemoteTrailers = [.. item.RemoteTrailers, mediaUrl]; } } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 5fa1213db3..b1f7f29bad 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -384,6 +384,7 @@ namespace MediaBrowser.Controller.Entities cancellationToken.ThrowIfCancellationRequested(); var validChildren = new List<BaseItem>(); + var accessibleChildren = new List<BaseItem>(); var validChildrenNeedGeneration = false; if (IsFileProtocol) @@ -438,12 +439,19 @@ namespace MediaBrowser.Controller.Entities { if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot)) { + // Preserve inaccessible items so they aren't treated as removed. + if (currentChildren.TryGetValue(child.Id, out var childrenToKeep)) + { + validChildren.Add(childrenToKeep); + } + continue; } if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild)) { validChildren.Add(currentChild); + accessibleChildren.Add(currentChild); if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None) { @@ -480,11 +488,12 @@ namespace MediaBrowser.Controller.Entities child.SetParent(this); newItems.Add(child); validChildren.Add(child); + accessibleChildren.Add(child); } // That's all the new and changed ones - now see if any have been removed and need cleanup var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); - var shouldRemove = !IsRoot || allowRemoveRoot; + // If it's an AggregateFolder, don't remove // Collect replaced primaries for deferred deletion (after CreateItems) var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>(); @@ -497,7 +506,7 @@ namespace MediaBrowser.Controller.Entities .Where(p => !string.IsNullOrEmpty(p)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - if (shouldRemove && itemsRemoved.Count > 0) + if (itemsRemoved.Count > 0) { foreach (var item in itemsRemoved) { @@ -703,7 +712,7 @@ namespace MediaBrowser.Controller.Entities validChildrenNeedGeneration = false; } - await ValidateSubFolders(validChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false); + await ValidateSubFolders(accessibleChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false); } if (refreshChildMetadata) @@ -742,7 +751,7 @@ namespace MediaBrowser.Controller.Entities validChildren = Children.ToList(); } - await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false); + await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false); } } } @@ -906,7 +915,10 @@ namespace MediaBrowser.Controller.Entities query.Parent = this; } - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet) + // BoxSets and Playlists can have per-user visibility (shares/open access) that is stored in the + // serialized item data and cannot be evaluated by the database query, so filter them in memory. + if (query.IncludeItemTypes.Length > 0 + && query.IncludeItemTypes.All(t => t == BaseItemKind.BoxSet || t == BaseItemKind.Playlist)) { return QueryWithPostFiltering(query); } @@ -927,7 +939,7 @@ namespace MediaBrowser.Controller.Entities if (user is not null) { - // needed for boxsets + // needed for boxsets and playlists itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User)); } 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 1e5b5aa164..3b1f6a961f 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -21,6 +21,7 @@ namespace MediaBrowser.Controller.Entities AlbumArtistIds = []; AlbumIds = []; AncestorIds = []; + LinkedChildAncestorIds = []; ArtistIds = []; BlockUnratedItems = []; BoxSetLibraryFolders = []; @@ -71,6 +72,102 @@ namespace MediaBrowser.Controller.Entities } } + /// <summary> + /// Gets a value indicating whether the query carries any criteria that narrows the + /// result set, as opposed to user context, pagination, sorting or DTO options. + /// </summary> + public bool HasFilters => + IncludeItemTypes.Length > 0 + || ExcludeItemTypes.Length > 0 + || Genres.Count > 0 + || GenreIds.Count > 0 + || Years.Length > 0 + || Tags.Length > 0 + || ExcludeTags.Length > 0 + || OfficialRatings.Length > 0 + || StudioIds.Length > 0 + || ArtistIds.Length > 0 + || AlbumArtistIds.Length > 0 + || ContributingArtistIds.Length > 0 + || ExcludeArtistIds.Length > 0 + || AlbumIds.Length > 0 + || PersonIds.Length > 0 + || PersonTypes.Length > 0 + || MediaTypes.Length > 0 + || VideoTypes.Length > 0 + || ImageTypes.Length > 0 + || SeriesStatuses.Length > 0 + || ItemIds.Length > 0 + || ExcludeItemIds.Length > 0 + || AudioLanguages.Count > 0 + || SubtitleLanguages.Count > 0 + || LinkedChildAncestorIds.Length > 0 + || AncestorIds.Length > 0 + || IsFavorite.HasValue + || IsFavoriteOrLiked.HasValue + || IsLiked.HasValue + || IsPlayed.HasValue + || IsResumable.HasValue + || IsFolder.HasValue + || IsMissing.HasValue + || IsUnaired.HasValue + || IsSpecialSeason.HasValue + || Is3D.HasValue + || IsHD.HasValue + || Is4K.HasValue + || IsLocked.HasValue + || IsPlaceHolder.HasValue + || IsMovie.HasValue + || IsSports.HasValue + || IsKids.HasValue + || IsNews.HasValue + || IsSeries.HasValue + || IsAiring.HasValue + || IsVirtualItem.HasValue + || HasImdbId.HasValue + || HasTmdbId.HasValue + || HasTvdbId.HasValue + || HasOverview.HasValue + || HasOfficialRating.HasValue + || HasParentalRating.HasValue + || HasThemeSong.HasValue + || HasThemeVideo.HasValue + || HasSubtitles.HasValue + || HasSpecialFeature.HasValue + || HasTrailer.HasValue + || HasChapterImages.HasValue + || MinCriticRating.HasValue + || MinCommunityRating.HasValue + || MinParentalRating is not null + || MinIndexNumber.HasValue + || MinParentAndIndexNumber.HasValue + || IndexNumber.HasValue + || ParentIndexNumber.HasValue + || AiredDuringSeason.HasValue + || MinWidth.HasValue + || MinHeight.HasValue + || MaxWidth.HasValue + || MaxHeight.HasValue + || MinPremiereDate.HasValue + || MaxPremiereDate.HasValue + || MinStartDate.HasValue + || MaxStartDate.HasValue + || MinEndDate.HasValue + || MaxEndDate.HasValue + || MinDateCreated.HasValue + || MinDateLastSaved.HasValue + || MinDateLastSavedForUser.HasValue + || AdjacentTo.HasValue + || !string.IsNullOrEmpty(NameStartsWith) + || !string.IsNullOrEmpty(NameStartsWithOrGreater) + || !string.IsNullOrEmpty(NameLessThan) + || !string.IsNullOrEmpty(NameContains) + || !string.IsNullOrEmpty(MinSortName) + || !string.IsNullOrEmpty(Name) + || !string.IsNullOrEmpty(Person) + || !string.IsNullOrEmpty(SearchTerm) + || !string.IsNullOrEmpty(Path); + public bool Recursive { get; set; } public int? StartIndex { get; set; } @@ -265,6 +362,12 @@ namespace MediaBrowser.Controller.Entities public Guid[] AncestorIds { get; set; } + /// <summary> + /// Gets or sets a list of ancestor ids that the item's linked children must descend from. + /// Useful for filtering BoxSets/Playlists to only those that contain items from a specific library. + /// </summary> + public Guid[] LinkedChildAncestorIds { get; set; } + public Guid[] TopParentIds { get; set; } public CollectionType?[] PresetViews { get; set; } diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 5cc4d322f7..14325d971a 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; 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/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index dbe6f94dfd..42e4f79942 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -153,6 +153,12 @@ namespace MediaBrowser.Controller.Entities.TV return 16.0 / 9; } + /// <inheritdoc /> + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index f70f7dfb4c..e96ed05a5e 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -128,6 +128,12 @@ namespace MediaBrowser.Controller.Entities.TV return result; } + /// <inheritdoc /> + public override string GetInheritedOriginalLanguage() + { + return OriginalLanguage ?? Series?.GetInheritedOriginalLanguage(); + } + public override string CreatePresentationUniqueKey() { if (IndexNumber.HasValue) diff --git a/MediaBrowser.Controller/Entities/TagExtensions.cs b/MediaBrowser.Controller/Entities/TagExtensions.cs index c1e4d1db2f..07c2298fce 100644 --- a/MediaBrowser.Controller/Entities/TagExtensions.cs +++ b/MediaBrowser.Controller/Entities/TagExtensions.cs @@ -15,6 +15,7 @@ namespace MediaBrowser.Controller.Entities throw new ArgumentNullException(nameof(name)); } + name = name.Trim(); var current = item.Tags; if (!current.Contains(name, StringComparison.OrdinalIgnoreCase)) @@ -25,7 +26,7 @@ namespace MediaBrowser.Controller.Entities } else { - item.Tags = [..current, name]; + item.Tags = [.. current, name]; } } } diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs index deed3631b8..d5be997b84 100644 --- a/MediaBrowser.Controller/Entities/UserRootFolder.cs +++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs @@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { - if (query.Recursive) + // The user root holds no items of its own - a plain listing returns the user's + // views. But a request carrying any filter is a search across the libraries, so + // resolve it through the recursive query path even when Recursive wasn't set; + // otherwise the filters would be silently dropped. Recursive is set so the + // downstream query (ancestor/top-parent scoping) treats it as a recursive search. + if (query.Recursive || query.HasFilters) { + query.Recursive = true; return QueryRecursive(query); } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index cb05056601..c57ed2faf8 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities case CollectionType.folders: return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query); + case CollectionType.books: + return GetBooks(queryParent, user, query); + case CollectionType.tvshows: return GetTvView(queryParent, user, query); @@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities return _libraryManager.GetItemsResult(query); } + private QueryResult<BaseItem> GetBooks(Folder parent, User user, InternalItemsQuery query) + { + query.Recursive = true; + query.Parent = parent; + query.SetUser(user); + + query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook }; + + return _libraryManager.GetItemsResult(query); + } + private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query) { query.Recursive = true; diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 80bcd62dcd..0606fe1870 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -10,6 +10,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; @@ -33,11 +34,11 @@ namespace MediaBrowser.Controller.Entities { public Video() { - AdditionalParts = Array.Empty<string>(); - LocalAlternateVersions = Array.Empty<string>(); - SubtitleFiles = Array.Empty<string>(); - AudioFiles = Array.Empty<string>(); - LinkedAlternateVersions = Array.Empty<LinkedChild>(); + AdditionalParts = []; + LocalAlternateVersions = []; + SubtitleFiles = []; + AudioFiles = []; + LinkedAlternateVersions = []; } [JsonIgnore] @@ -253,7 +254,7 @@ namespace MediaBrowser.Controller.Entities private int GetMediaSourceCount(HashSet<Guid> callstack = null) { - callstack ??= new(); + callstack ??= []; if (PrimaryVersionId.HasValue) { var item = LibraryManager.GetItemById(PrimaryVersionId.Value); @@ -278,6 +279,17 @@ namespace MediaBrowser.Controller.Entities return linkedVersionCount + localVersionCount + 1; } + /// <inheritdoc /> + public override string GetInheritedOriginalLanguage() + { + if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer) + { + return GetOwner()?.GetInheritedOriginalLanguage(); + } + + return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage(); + } + public override List<string> GetUserDataKeys() { var list = base.GetUserDataKeys(); @@ -323,6 +335,102 @@ namespace MediaBrowser.Controller.Entities PresentationUniqueKey = CreatePresentationUniqueKey(); } + /// <summary> + /// Marks the played status of this video and propagates it to its alternate versions. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="datePlayed">The date played.</param> + /// <param name="resetPosition">if set to <c>true</c> [reset position].</param> + public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition) + { + base.MarkPlayed(user, datePlayed, resetPosition); + PropagatePlayedState(user, true, resetPosition); + } + + /// <summary> + /// Marks this video unplayed and propagates the change to its alternate versions. + /// </summary> + /// <param name="user">The user.</param> + public override void MarkUnplayed(User user) + { + base.MarkUnplayed(user); + + // MarkUnplayed always clears the position on this video, so reset the versions too. + PropagatePlayedState(user, false, true); + } + + /// <summary> + /// Propagates the played status to every alternate version of this video. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="played">The played status to apply to the alternate versions.</param> + /// <param name="resetPosition">When marking played, controls whether each version's resume point + /// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed, + /// which always fully resets every version.</param> + public void PropagatePlayedState(User user, bool played, bool resetPosition = true) + { + ArgumentNullException.ThrowIfNull(user); + + if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions) + { + return; + } + + foreach (var (item, _) in GetAllItemsForMediaSources()) + { + if (item.Id.Equals(Id) || item is not Video) + { + continue; + } + + if (played) + { + var dto = new UpdateUserItemDataDto { Played = true }; + if (resetPosition) + { + dto.PlaybackPositionTicks = 0; + } + + // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved. + UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed); + } + else + { + var data = UserDataManager.GetUserData(user, item); + if (data is null) + { + continue; + } + + ResetPlayedState(data); + UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None); + } + } + } + + /// <summary> + /// Gets this video together with all of its alternate versions (local and linked and, when this + /// is itself an alternate, the primary and the primary's other versions), deduplicated. + /// </summary> + /// <returns>This video and every alternate version of it.</returns> + public IReadOnlyList<Video> GetAllVersions() + { + return GetAllItemsForMediaSources() + .Select(i => i.Item) + .OfType<Video>() + .ToList(); + } + + /// <summary> + /// Gets the alternate version of this video that matches the supplied item id. + /// </summary> + /// <param name="itemId">The version item id (the playback media source id).</param> + /// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns> + public Video GetAlternateVersion(Guid itemId) + { + return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId)); + } + public override string CreatePresentationUniqueKey() { if (PrimaryVersionId.HasValue) @@ -379,13 +487,13 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Gets the additional parts. /// </summary> + /// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param> /// <returns>IEnumerable{Video}.</returns> - public IOrderedEnumerable<Video> GetAdditionalParts() + public IOrderedEnumerable<Video> GetAdditionalParts(User user = null) { return GetAdditionalPartIds() - .Select(i => LibraryManager.GetItemById(i)) - .Where(i => i is not null) - .OfType<Video>() + .Select(i => LibraryManager.GetItemById<Video>(i)) + .Where(i => i is not null && (user is null || i.IsVisible(user))) .OrderBy(i => i.SortName); } @@ -630,39 +738,58 @@ namespace MediaBrowser.Controller.Entities }).FirstOrDefault(); } - protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() + /// <summary> + /// Gets the ids of the items whose owned extras belong to this item. + /// Extras are linked to a single version but need tp be surfaced for all versions. + /// </summary> + /// <returns>An array containing the owner ids.</returns> + protected override Guid[] GetExtraOwnerIds() { - var list = new List<(BaseItem, MediaSourceType)> - { - (this, MediaSourceType.Default) - }; - - list.AddRange( - LibraryManager.GetLinkedAlternateVersions(this) - .Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + return GetAllItemsForMediaSources() + .Select(i => i.Item.Id) + .Distinct() + .ToArray(); + } - if (PrimaryVersionId.HasValue) - { - if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary) - { - var existingIds = list.Select(i => i.Item1.Id).ToList(); - list.Add((primary, MediaSourceType.Grouping)); - list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); - } - } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() + { + var primary = PrimaryVersionId.HasValue + ? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video + : null; + + var primaryLinked = primary is null + ? [] + : LibraryManager.GetLinkedAlternateVersions(primary).ToList(); + + // Grouping marks user-merged (splittable) sources. The primary is only such a source when + // this video is linked onto it; for local (file-based) alternates the primary is just + // another default source. + var primaryType = primaryLinked.Any(i => i.Id.Equals(Id)) + ? MediaSourceType.Grouping + : MediaSourceType.Default; + + // This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates. + var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) } + .Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping))) + .Concat(primary is null + ? [] + : primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType))) + .ToList(); - var localAlternates = list - .SelectMany(i => - { - return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>(); - }) + // The local (file-based) alternate versions of every grouped item. + var localAlternates = grouped + .Select(i => i.Item1) + .OfType<Video>() + .SelectMany(LibraryManager.GetLocalAlternateVersionIds) .Select(LibraryManager.GetItemById) .Where(i => i is not null) - .ToList(); - - list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default))); + .Select(i => (i, MediaSourceType.Default)); - return list; + // Deduplicate + return grouped + .Concat(localAlternates) + .DistinctBy(i => i.Item1.Id) + .ToList(); } } } diff --git a/MediaBrowser.Controller/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs index f69f4586c6..b2eb8fc3f1 100644 --- a/MediaBrowser.Controller/IO/IExternalDataManager.cs +++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs @@ -16,4 +16,11 @@ public interface IExternalDataManager /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// <summary> + /// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images). + /// Use this when DB-side cleanup is already handled by another code path (e.g. <c>IItemPersistenceService.DeleteItem</c>). + /// </summary> + /// <param name="item">The item.</param> + void DeleteExternalItemFiles(BaseItem item); } diff --git a/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..af49711606 --- /dev/null +++ b/MediaBrowser.Controller/Library/IBatchLocalSimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// A local similar items provider that supports batch queries across multiple source items. +/// Implementations share access filtering and entity loading across all sources for better performance. +/// </summary> +public interface IBatchLocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Gets similar items for multiple source items in a single batch. + /// </summary> + /// <param name="sourceItems">The source items to find similar items for.</param> + /// <param name="query">The query options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Per-source-item results keyed by source item ID.</returns> + Task<Dictionary<Guid, IReadOnlyList<BaseItem>>> GetBatchSimilarItemsAsync( + IReadOnlyList<BaseItem> sourceItems, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IExternalSearchProvider.cs b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs new file mode 100644 index 0000000000..bded8ba3a3 --- /dev/null +++ b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Interface for external search providers that offer enhanced search capabilities. +/// </summary> +public interface IExternalSearchProvider : ISearchProvider +{ + /// <summary> + /// Searches for items matching the query. + /// </summary> + /// <param name="query">The search query.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Async enumerable of search results with relevance scores.</returns> + new IAsyncEnumerable<SearchResult> SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IInternalSearchProvider.cs b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs new file mode 100644 index 0000000000..f87931395d --- /dev/null +++ b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs @@ -0,0 +1,8 @@ +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Marker interface for internal search providers that typically query the local database directly. +/// </summary> +public interface IInternalSearchProvider : ISearchProvider +{ +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f4c2196400..0b64da291c 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -598,6 +598,14 @@ namespace MediaBrowser.Controller.Library IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery query); /// <summary> + /// Gets the distinct people names per item for multiple items. + /// </summary> + /// <param name="itemIds">The item IDs.</param> + /// <param name="personTypes">The person types to include.</param> + /// <returns>A dictionary mapping each item ID to its distinct people names. Items with no matching people are omitted.</returns> + IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes); + + /// <summary> /// Queries the items. /// </summary> /// <param name="query">The query.</param> diff --git a/MediaBrowser.Controller/Library/ISearchEngine.cs b/MediaBrowser.Controller/Library/ISearchEngine.cs deleted file mode 100644 index 31dcbba5bd..0000000000 --- a/MediaBrowser.Controller/Library/ISearchEngine.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Search; - -namespace MediaBrowser.Controller.Library -{ - /// <summary> - /// Interface ILibrarySearchEngine. - /// </summary> - public interface ISearchEngine - { - /// <summary> - /// Gets the search hints. - /// </summary> - /// <param name="query">The query.</param> - /// <returns>Task{IEnumerable{SearchHintInfo}}.</returns> - QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query); - } -} diff --git a/MediaBrowser.Controller/Library/ISearchManager.cs b/MediaBrowser.Controller/Library/ISearchManager.cs new file mode 100644 index 0000000000..4f763829a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchManager.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Orchestrates search operations across registered search providers. +/// </summary> +public interface ISearchManager +{ + /// <summary> + /// Searches for items and returns hints suitable for autocomplete/typeahead UI. + /// Results are ordered by relevance score from search providers. + /// </summary> + /// <param name="query">The search query including filters and pagination.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Paginated search hints with item metadata for display.</returns> + Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync( + SearchQuery query, + CancellationToken cancellationToken = default); + + /// <summary> + /// Gets ranked search results from registered providers. Returns only item IDs and + /// relevance scores; callers are responsible for loading items and applying user-access filtering. + /// </summary> + /// <param name="query">The search provider query with type/media filters.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Search results containing item IDs and relevance scores.</returns> + Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync( + SearchProviderQuery query, + CancellationToken cancellationToken = default); + + /// <summary> + /// Registers search providers discovered through dependency injection. + /// Called during application startup. + /// </summary> + /// <param name="providers">The search providers to register.</param> + void AddParts(IEnumerable<ISearchProvider> providers); + + /// <summary> + /// Gets all registered search providers ordered by priority. + /// </summary> + /// <returns>The list of search providers including the SQL fallback provider.</returns> + IReadOnlyList<ISearchProvider> GetProviders(); +} diff --git a/MediaBrowser.Controller/Library/ISearchProvider.cs b/MediaBrowser.Controller/Library/ISearchProvider.cs new file mode 100644 index 0000000000..3b300ed38b --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Interface for search providers. +/// </summary> +public interface ISearchProvider +{ + /// <summary> + /// Gets the name of the provider. + /// </summary> + string Name { get; } + + /// <summary> + /// Gets the type of the provider. + /// </summary> + MetadataPluginType Type { get; } + + /// <summary> + /// Gets the priority of the provider. Lower values execute first. + /// </summary> + int Priority { get; } + + /// <summary> + /// Searches for items matching the query. + /// </summary> + /// <param name="query">The search query.</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>Ranked list of candidate item IDs with scores.</returns> + Task<IReadOnlyList<SearchResult>> SearchAsync( + SearchProviderQuery query, + CancellationToken cancellationToken); + + /// <summary> + /// Determines whether this provider can handle the given query. + /// </summary> + /// <param name="query">The search query to evaluate.</param> + /// <returns>True if this provider can search for the query; otherwise, false.</returns> + bool CanSearch(SearchProviderQuery query); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs index 0ced6f71ee..36fa547eeb 100644 --- a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -6,6 +6,7 @@ using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; namespace MediaBrowser.Controller.Library; @@ -47,4 +48,23 @@ public interface ISimilarItemsManager int? limit, LibraryOptions? libraryOptions, CancellationToken cancellationToken); + + /// <summary> + /// Builds movie recommendations for a user: a mix of similar-items and person-based categories, + /// scheduled round-robin and capped to <paramref name="categoryLimit"/>. + /// </summary> + /// <param name="user">The user the recommendations are for. May be <see langword="null"/> for anonymous access.</param> + /// <param name="parentId">The library/folder to localize the search to. Pass <see cref="Guid.Empty"/> to use the root.</param> + /// <param name="categoryLimit">Maximum number of recommendation categories to return.</param> + /// <param name="itemLimit">Maximum number of items per category.</param> + /// <param name="dtoOptions">DTO options used when querying the library.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The list of recommendation categories, ordered by <see cref="RecommendationType"/>.</returns> + Task<IReadOnlyList<SimilarItemsRecommendation>> GetMovieRecommendationsAsync( + User? user, + Guid parentId, + int categoryLimit, + int itemLimit, + DtoOptions dtoOptions, + CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index 798812bf1f..2ee8845346 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -63,6 +63,24 @@ namespace MediaBrowser.Controller.Library Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user); /// <summary> + /// Gets the user data that should drive resume for a multi-version item: the data of the most + /// recently played alternate version (including the item itself) that has a resume point. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <returns>The resume version's data, or <c>null</c> when the item has no versions or none has a resume point.</returns> + VersionResumeData? GetResumeUserData(User user, BaseItem item); + + /// <summary> + /// Gets the resume-driving user data for multiple items in a single batch operation. + /// See <see cref="GetResumeUserData(User, BaseItem)"/>. + /// </summary> + /// <param name="items">The items to get resume data for.</param> + /// <param name="user">The user.</param> + /// <returns>A dictionary mapping item ids to their resume version's data; items without one are omitted.</returns> + IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user); + + /// <summary> /// Gets the user data dto. /// </summary> /// <param name="item">Item to use.</param> @@ -80,5 +98,13 @@ namespace MediaBrowser.Controller.Library /// <param name="reportedPositionTicks">New playstate.</param> /// <returns>True if playstate was updated.</returns> bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks); + + /// <summary> + /// Clears any stored audio and subtitle stream selections for the given user/item pair. + /// Used when the user has opted out of remembering selections. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + void ResetPlaybackStreamSelections(User user, BaseItem item); } } diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs index b558ef73d5..c5e7ae4913 100644 --- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -117,7 +117,7 @@ namespace MediaBrowser.Controller.Library get { var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : [Path]; - return AdditionalLocations is null ? paths : [..paths, ..AdditionalLocations]; + return AdditionalLocations is null ? paths : [.. paths, .. AdditionalLocations]; } } diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs new file mode 100644 index 0000000000..845588c872 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs @@ -0,0 +1,45 @@ +using System; +using Jellyfin.Data.Enums; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Query object for search providers. +/// </summary> +public class SearchProviderQuery +{ + /// <summary> + /// Gets the search term. + /// </summary> + public required string SearchTerm { get; init; } + + /// <summary> + /// Gets the user ID for user-specific searches. + /// </summary> + public Guid? UserId { get; init; } + + /// <summary> + /// Gets the item types to include in the search. + /// </summary> + public BaseItemKind[] IncludeItemTypes { get; init; } = []; + + /// <summary> + /// Gets the item types to exclude from the search. + /// </summary> + public BaseItemKind[] ExcludeItemTypes { get; init; } = []; + + /// <summary> + /// Gets the media types to include in the search. + /// </summary> + public MediaType[] MediaTypes { get; init; } = []; + + /// <summary> + /// Gets the maximum number of results to return. + /// </summary> + public int? Limit { get; init; } + + /// <summary> + /// Gets the parent ID to scope the search. + /// </summary> + public Guid? ParentId { get; init; } +} diff --git a/MediaBrowser.Controller/Library/SearchResult.cs b/MediaBrowser.Controller/Library/SearchResult.cs new file mode 100644 index 0000000000..e6f145e979 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchResult.cs @@ -0,0 +1,60 @@ +using System; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Represents an item matched by a search query with its relevance score. +/// </summary> +public readonly struct SearchResult : IEquatable<SearchResult> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SearchResult"/> struct. + /// </summary> + /// <param name="itemId">The item ID.</param> + /// <param name="score">The relevance score.</param> + public SearchResult(Guid itemId, float score) + { + ItemId = itemId; + Score = score; + } + + /// <summary> + /// Gets the ID of the matching item. + /// </summary> + public Guid ItemId { get; init; } + + /// <summary> + /// Gets the relevance score. Higher values indicate more relevant results. + /// </summary> + public float Score { get; init; } + + /// <summary> + /// Compares two <see cref="SearchResult"/> instances for equality. + /// </summary> + /// <param name="left">The left operand.</param> + /// <param name="right">The right operand.</param> + /// <returns>True if the instances are equal; otherwise, false.</returns> + public static bool operator ==(SearchResult left, SearchResult right) + => left.Equals(right); + + /// <summary> + /// Compares two <see cref="SearchResult"/> instances for inequality. + /// </summary> + /// <param name="left">The left operand.</param> + /// <param name="right">The right operand.</param> + /// <returns>True if the instances are not equal; otherwise, false.</returns> + public static bool operator !=(SearchResult left, SearchResult right) + => !left.Equals(right); + + /// <inheritdoc/> + public override bool Equals(object? obj) + => obj is SearchResult other && Equals(other); + + /// <inheritdoc/> + public bool Equals(SearchResult other) + => ItemId.Equals(other.ItemId) && Score.Equals(other.Score); + + /// <inheritdoc/> + public override int GetHashCode() + => HashCode.Combine(ItemId, Score); +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs new file mode 100644 index 0000000000..71346fcadf --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsRecommendation.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// A recommendation category derived from a baseline item, holding similar items prior to DTO conversion. +/// </summary> +public sealed class SimilarItemsRecommendation +{ + /// <summary> + /// Gets the display name of the baseline item the recommendation is based on. + /// </summary> + public required string BaselineItemName { get; init; } + + /// <summary> + /// Gets an identifier for the recommendation category. + /// </summary> + public required Guid CategoryId { get; init; } + + /// <summary> + /// Gets the recommendation type. + /// </summary> + public required RecommendationType RecommendationType { get; init; } + + /// <summary> + /// Gets the similar items for the baseline, ordered by relevance. + /// </summary> + public required IReadOnlyList<BaseItem> Items { get; init; } +} diff --git a/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs new file mode 100644 index 0000000000..1766c50141 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// Single definition of "which alternate version was most recently played" shared by the resume tile + /// (<see cref="IUserDataManager.GetResumeUserData"/>), the media-source default ordering and Next Up. + /// Each call site declares its own eligibility rule so the intentional differences (resumable-only vs. + /// resumable-or-completed) are visible in one place instead of being re-implemented divergently. + /// The SQL resume query keeps its own translation of the same rule. + /// </summary> + public static class VersionPlaybackSelector + { + /// <summary> + /// Selects the entry whose user data has the greatest <see cref="UserItemData.LastPlayedDate"/>, + /// considering only entries that satisfy <paramref name="isEligible"/>. On an exact tie the first + /// encountered entry wins. + /// </summary> + /// <typeparam name="T">The candidate type (e.g. a version item or a media source).</typeparam> + /// <param name="items">The candidates to choose from.</param> + /// <param name="dataSelector">Resolves the user data for a candidate, or <c>null</c> when it has none.</param> + /// <param name="isEligible">Whether a candidate's user data makes it a valid winner.</param> + /// <returns>The most recently played eligible candidate, or <c>default</c> when none qualify.</returns> + public static T? SelectMostRecentlyPlayed<T>( + IEnumerable<T> items, + Func<T, UserItemData?> dataSelector, + Func<UserItemData, bool> isEligible) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(dataSelector); + ArgumentNullException.ThrowIfNull(isEligible); + + T? winner = default; + var winnerDate = DateTime.MinValue; + var hasWinner = false; + + foreach (var item in items) + { + var data = dataSelector(item); + if (data is null || !isEligible(data)) + { + continue; + } + + var date = data.LastPlayedDate ?? DateTime.MinValue; + if (!hasWinner || date > winnerDate) + { + winner = item; + winnerDate = date; + hasWinner = true; + } + } + + return winner; + } + } +} diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs new file mode 100644 index 0000000000..772e2bf3a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/VersionResumeData.cs @@ -0,0 +1,41 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Library +{ + /// <summary> + /// The user data of the most recently played alternate version that should drive the completion state of a multi-version item. + /// </summary> + /// <param name="VersionId">The id of the version that owns <paramref name="UserData"/>.</param> + /// <param name="UserData">The resume version's user data.</param> + public record VersionResumeData(Guid VersionId, UserItemData UserData) + { + /// <summary> + /// Merges the most recently played version's completion state into the supplied user data dto. + /// Completion (played) propagates to the primary. An in-progress resume position stays on the version + /// that owns it, which is surfaced directly (e.g. in resume queries) so that playback always targets + /// the correct version rather than resuming the primary at another version's offset. When the movie was + /// finished on a different version, the primary's own stale resume position is cleared so it does not + /// render as "watched and resumable" at the same time. + /// </summary> + /// <param name="dto">The user data dto to update.</param> + public void ApplyTo(UserItemDataDto dto) + { + dto.Played = dto.Played || UserData.Played; + + if ((UserData.LastPlayedDate ?? DateTime.MinValue) > (dto.LastPlayedDate ?? DateTime.MinValue)) + { + dto.LastPlayedDate = UserData.LastPlayedDate; + } + + // A different version was finished (played, no resume position of its own) and is the most + // recently played: the whole movie is watched. + if (!VersionId.Equals(dto.ItemId) && UserData.Played && UserData.PlaybackPositionTicks <= 0) + { + dto.PlaybackPositionTicks = 0; + dto.PlayedPercentage = null; + } + } + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 65f6b79656..1b0bbe9ea0 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -25,6 +25,7 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Configuration; using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; @@ -86,6 +87,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0); private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1); private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0); + private readonly Version _minFFmpegNoiseBsfDrop = new Version(5, 0); private static readonly string[] _videoProfilesH264 = [ @@ -443,6 +445,13 @@ namespace MediaBrowser.Controller.MediaEncoding || state.VideoStream.VideoRangeType == VideoRangeType.HLG); } + private static bool IsDeinterlaceAvailable(EncodingJobInfo state) + { + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + return doDeintH264 || doDeintHevc; + } + private bool IsVideoStreamHevcRext(EncodingJobInfo state) { var videoStream = state.VideoStream; @@ -1267,16 +1276,13 @@ namespace MediaBrowser.Controller.MediaEncoding .Append(_mediaEncoder.GetInputPathArgument(state)); } - // sub2video for external graphical subtitles - if (state.SubtitleStream is not null - && ShouldEncodeSubtitle(state) - && !state.SubtitleStream.IsTextSubtitleStream - && state.SubtitleStream.IsExternal) + if (NeedsExternalSubtitleMuxing(state)) { var subtitlePath = state.SubtitleStream.Path; - var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan()); + var isGraphicalBurnIn = ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream; // dvdsub/vobsub graphical subtitles use .sub+.idx pairs + var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan()); if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) { var idxFile = Path.ChangeExtension(subtitlePath, ".idx"); @@ -1307,7 +1313,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekSubParam); } - if (!string.IsNullOrEmpty(canvasArgs)) + if (isGraphicalBurnIn && !string.IsNullOrEmpty(canvasArgs)) { arg.Append(canvasArgs); } @@ -1550,20 +1556,61 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) { - var bitStreamArgs = string.Empty; + var filters = new List<string>(); + + var noiseFilter = GetCopiedAudioTrimBsf(state); + if (!string.IsNullOrEmpty(noiseFilter)) + { + filters.Add(noiseFilter); + } + var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.'); // Apply aac_adtstoasc bitstream filter when media source is in mpegts. if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase) && (string.Equals(mediaSourceContainer, "ts", StringComparison.OrdinalIgnoreCase) || string.Equals(mediaSourceContainer, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))) + || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)) + && IsAAC(state.AudioStream)) + { + filters.Add("aac_adtstoasc"); + } + + return filters.Count == 0 + ? string.Empty + : " -bsf:a " + string.Join(',', filters); + } + + // When video is transcoded, accurate_seek (the default) trims video to the + // exact seek point via decoder-side frame discard. But stream-copied audio + // bypasses the decoder, so it starts from the nearest keyframe — potentially + // seconds before the target. Use the noise bsf to drop copied audio packets + // before the seek target, achieving the same trim precision without + // re-encoding. The noise bsf's drop= parameter requires ffmpeg >= 5.0. + // Important: make sure not to use it with wtv because it breaks seeking + private string GetCopiedAudioTrimBsf(EncodingJobInfo state) + { + if (state.TranscodingType is not TranscodingJobType.Hls + || !state.IsVideoRequest + || IsCopyCodec(state.OutputVideoCodec) + || !IsCopyCodec(state.OutputAudioCodec) + || string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase) + || _mediaEncoder.EncoderVersion < _minFFmpegNoiseBsfDrop) { - bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Audio); - bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs; + return null; } - return bitStreamArgs; + var startTicks = state.BaseRequest.StartTimeTicks ?? 0; + if (startTicks <= 0) + { + return null; + } + + var seekSeconds = startTicks / (double)TimeSpan.TicksPerSecond; + return string.Format( + CultureInfo.InvariantCulture, + "noise=drop='lt(pts*tb\\,{0:F3})'", + seekSeconds); } public static string GetSegmentFileExtension(string segmentContainer) @@ -1766,13 +1813,13 @@ namespace MediaBrowser.Controller.MediaEncoding { param += encoderPreset switch { - EncoderPreset.veryslow => " -preset p7", - EncoderPreset.slower => " -preset p6", - EncoderPreset.slow => " -preset p5", - EncoderPreset.medium => " -preset p4", - EncoderPreset.fast => " -preset p3", - EncoderPreset.faster => " -preset p2", - _ => " -preset p1" + EncoderPreset.veryslow => " -preset p7", + EncoderPreset.slower => " -preset p6", + EncoderPreset.slow => " -preset p5", + EncoderPreset.medium => " -preset p4", + EncoderPreset.fast => " -preset p3", + EncoderPreset.faster => " -preset p2", + _ => " -preset p1" }; } else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) @@ -1782,11 +1829,11 @@ namespace MediaBrowser.Controller.MediaEncoding { param += encoderPreset switch { - EncoderPreset.veryslow => " -quality quality", - EncoderPreset.slower => " -quality quality", - EncoderPreset.slow => " -quality quality", - EncoderPreset.medium => " -quality balanced", - _ => " -quality speed" + EncoderPreset.veryslow => " -quality quality", + EncoderPreset.slower => " -quality quality", + EncoderPreset.slow => " -quality quality", + EncoderPreset.medium => " -quality balanced", + _ => " -quality speed" }; if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) @@ -1806,11 +1853,11 @@ namespace MediaBrowser.Controller.MediaEncoding { param += encoderPreset switch { - EncoderPreset.veryslow => " -prio_speed 0", - EncoderPreset.slower => " -prio_speed 0", - EncoderPreset.slow => " -prio_speed 0", - EncoderPreset.medium => " -prio_speed 0", - _ => " -prio_speed 1" + EncoderPreset.veryslow => " -prio_speed 0", + EncoderPreset.slower => " -prio_speed 0", + EncoderPreset.slow => " -prio_speed 0", + EncoderPreset.medium => " -prio_speed 0", + _ => " -prio_speed 1" }; } @@ -2017,11 +2064,15 @@ namespace MediaBrowser.Controller.MediaEncoding args += keyFrameArg + gopArg; } - // global_header produced by AMD HEVC VA-API encoder causes non-playable fMP4 on iOS + // The in-band Parameter Sets generated by the AMD HEVC VA-API encoder is inconsistent + // with the extradata generated by ffmpeg, causing decoding failures when using hvc1. if (string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) && _mediaEncoder.IsVaapiDeviceAmd) { - args += " -flags:v -global_header"; + // Extracting the extradata from the in-band PS to bypass the issue. + // This can be removed once the issue is resolved in libva or Mesa. + // Transcoding is unavoidable here, so using BSF will not conflict with BSF in remuxing. + args += " -flags:v -global_header -bsf:v extract_extradata=remove=0"; } return args; @@ -2561,56 +2612,66 @@ namespace MediaBrowser.Controller.MediaEncoding } public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs) + => CanStreamCopyAudio(state, audioStream, supportedAudioCodecs, out _); + + /// <summary> + /// Determines whether the given audio stream can be stream-copied and, regardless of the outcome, + /// reports the codec/parameter incompatibilities that would force a re-encode via <paramref name="failureReasons"/>. + /// </summary> + /// <param name="state">The encoding job state.</param> + /// <param name="audioStream">The source audio stream.</param> + /// <param name="supportedAudioCodecs">The audio codecs the target supports.</param> + /// <param name="failureReasons">The codec/parameter incompatibilities preventing a copy, or <c>0</c> if the stream is copy-compatible.</param> + /// <returns><c>true</c> if the audio stream can be stream-copied; otherwise, <c>false</c>.</returns> + public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs, out TranscodeReason failureReasons) { var request = state.BaseRequest; - if (!request.AllowAudioStreamCopy) - { - return false; - } + // Policy-independent compatibility check, so the reasons are reported even when a policy gate is what ultimately prevents the copy. + failureReasons = GetAudioStreamCopyFailureReasons(state, audioStream, supportedAudioCodecs); + + return request.AllowAudioStreamCopy + && request.EnableAutoStreamCopy + && failureReasons == 0; + } + + private static TranscodeReason GetAudioStreamCopyFailureReasons(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs) + { + var request = state.BaseRequest; + TranscodeReason reasons = 0; var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec); if (maxBitDepth.HasValue && audioStream.BitDepth.HasValue && audioStream.BitDepth.Value > maxBitDepth.Value) { - return false; + reasons |= TranscodeReason.AudioBitDepthNotSupported; } // Source and target codecs must match if (string.IsNullOrEmpty(audioStream.Codec) || !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase)) { - return false; + reasons |= TranscodeReason.AudioCodecNotSupported; } // Channels must fall within requested value var channels = state.GetRequestedAudioChannels(audioStream.Codec); - if (channels.HasValue) + if (channels.HasValue + && (!audioStream.Channels.HasValue + || audioStream.Channels.Value <= 0 + || audioStream.Channels.Value > channels.Value)) { - if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0) - { - return false; - } - - if (audioStream.Channels.Value > channels.Value) - { - return false; - } + reasons |= TranscodeReason.AudioChannelsNotSupported; } // Sample rate must fall within requested value - if (request.AudioSampleRate.HasValue) + if (request.AudioSampleRate.HasValue + && (!audioStream.SampleRate.HasValue + || audioStream.SampleRate.Value <= 0 + || audioStream.SampleRate.Value > request.AudioSampleRate.Value)) { - if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0) - { - return false; - } - - if (audioStream.SampleRate.Value > request.AudioSampleRate.Value) - { - return false; - } + reasons |= TranscodeReason.AudioSampleRateNotSupported; } // Audio bitrate must fall within requested value @@ -2618,10 +2679,10 @@ namespace MediaBrowser.Controller.MediaEncoding && audioStream.BitRate.HasValue && audioStream.BitRate.Value > request.AudioBitRate.Value) { - return false; + reasons |= TranscodeReason.AudioBitrateNotSupported; } - return request.EnableAutoStreamCopy; + return reasons; } public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec) @@ -2762,25 +2823,29 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) { +#pragma warning disable SA1008 return (inputChannels, outputChannels) switch { - (>= 6, >= 6 or 0) => Math.Min(640000, bitrate), - (> 0, > 0) => Math.Min(outputChannels * 128000, bitrate), - (> 0, _) => Math.Min(inputChannels * 128000, bitrate), + ( >= 6, >= 6 or 0) => Math.Min(640000, bitrate), + ( > 0, > 0) => Math.Min(outputChannels * 128000, bitrate), + ( > 0, _) => Math.Min(inputChannels * 128000, bitrate), (_, _) => Math.Min(384000, bitrate) }; +#pragma warning restore SA1008 } if (string.Equals(audioCodec, "dts", StringComparison.OrdinalIgnoreCase) || string.Equals(audioCodec, "dca", StringComparison.OrdinalIgnoreCase)) { +#pragma warning disable SA1008 return (inputChannels, outputChannels) switch { - (>= 6, >= 6 or 0) => Math.Min(768000, bitrate), - (> 0, > 0) => Math.Min(outputChannels * 136000, bitrate), - (> 0, _) => Math.Min(inputChannels * 136000, bitrate), + ( >= 6, >= 6 or 0) => Math.Min(768000, bitrate), + ( > 0, > 0) => Math.Min(outputChannels * 136000, bitrate), + ( > 0, _) => Math.Min(inputChannels * 136000, bitrate), (_, _) => Math.Min(672000, bitrate) }; +#pragma warning restore SA1008 } // Empty bitrate area is not allow on iOS @@ -3001,23 +3066,6 @@ namespace MediaBrowser.Controller.MediaEncoding } seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick)); - - if (state.IsVideoRequest) - { - // If we are remuxing, then the copied stream cannot be seeked accurately (it will seek to the nearest - // keyframe). If we are using fMP4, then force all other streams to use the same inaccurate seeking to - // avoid A/V sync issues which cause playback issues on some devices. - // When remuxing video, the segment start times correspond to key frames in the source stream, so this - // option shouldn't change the seeked point that much. - // Important: make sure not to use it with wtv because it breaks seeking - if (state.TranscodingType is TranscodingJobType.Hls - && string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase) - && (IsCopyCodec(state.OutputVideoCodec) || IsCopyCodec(state.OutputAudioCodec)) - && !string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase)) - { - seekParam += " -noaccurate_seek"; - } - } } return seekParam; @@ -3072,11 +3120,8 @@ namespace MediaBrowser.Controller.MediaEncoding int audioStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.AudioStream); if (state.AudioStream.IsExternal) { - bool hasExternalGraphicsSubs = state.SubtitleStream is not null - && ShouldEncodeSubtitle(state) - && state.SubtitleStream.IsExternal - && !state.SubtitleStream.IsTextSubtitleStream; - int externalAudioMapIndex = hasExternalGraphicsSubs ? 2 : 1; + bool hasExternalSubAsInput = NeedsExternalSubtitleMuxing(state); + int externalAudioMapIndex = hasExternalSubAsInput ? 2 : 1; args += string.Format( CultureInfo.InvariantCulture, @@ -3104,12 +3149,31 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (subtitleMethod == SubtitleDeliveryMethod.Embed) { - int subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream); + if (state.SubtitleStream.IsExternal) + { + // External subtitle file is added as second FFmpeg input. + // For single-stream files (SRT/ASS/VTT) the in-file index is always 0. + // For multi-stream containers (MKS) we count how many streams from + // the same file appear before the selected one. + var inFileIndex = state.MediaSource.MediaStreams + .Where(s => string.Equals(s.Path, state.SubtitleStream.Path, StringComparison.Ordinal)) + .TakeWhile(s => s.Index != state.SubtitleStream.Index) + .Count(); - args += string.Format( - CultureInfo.InvariantCulture, - " -map 0:{0}", - subtitleStreamIndex); + args += string.Format( + CultureInfo.InvariantCulture, + " -map 1:{0}", + inFileIndex); + } + else + { + int subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream); + + args += string.Format( + CultureInfo.InvariantCulture, + " -map 0:{0}", + subtitleStreamIndex); + } } else if (state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream) { @@ -3804,9 +3868,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doToneMap = IsSwTonemapAvailable(state, options); var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI; @@ -3958,9 +4020,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isCuInCuOut = isNvDecoder && isNvencEncoder; var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doCuTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -3994,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add(swDeintFilter); } - var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p"; + var outFormat = doCuTonemap ? "p010le" : "yuv420p"; var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); // sw scale mainFilters.Add(swScaleFilter); @@ -4169,9 +4229,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doOclTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -4417,9 +4475,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isQsvInQsvOut = isHwDecoder && isQsvEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doVppTonemap = IsIntelVppTonemapAvailable(state, options); var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options); var doTonemap = doVppTonemap || doOclTonemap; @@ -4711,12 +4767,10 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isQsvInQsvOut = isHwDecoder && isQsvEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doTonemap = doVaVppTonemap || doOclTonemap; - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -5042,12 +5096,10 @@ namespace MediaBrowser.Controller.MediaEncoding var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doTonemap = doVaVppTonemap || doOclTonemap; - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -5279,10 +5331,8 @@ namespace MediaBrowser.Controller.MediaEncoding var isSwEncoder = !isVaapiEncoder; var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doVkTonemap = IsVulkanHwTonemapAvailable(state, options); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -5519,9 +5569,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965; var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doOclTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -5752,9 +5800,7 @@ namespace MediaBrowser.Controller.MediaEncoding var reqMaxH = state.BaseRequest.MaxHeight; var threeDFormat = state.MediaSource.Video3DFormat; - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options); var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options); var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface); @@ -5953,9 +5999,7 @@ namespace MediaBrowser.Controller.MediaEncoding && (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase) || vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase)); - var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); - var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); - var doDeintH2645 = doDeintH264 || doDeintHevc; + var doDeintH2645 = IsDeinterlaceAvailable(state); var doOclTonemap = IsHwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); @@ -6219,12 +6263,21 @@ namespace MediaBrowser.Controller.MediaEncoding overlayFilters?.RemoveAll(string.IsNullOrEmpty); var framerate = GetFramerateParam(state); - if (framerate.HasValue) + if (mainFilters is not null && framerate.HasValue) { - mainFilters.Insert(0, string.Format( - CultureInfo.InvariantCulture, - "fps={0}", - framerate.Value)); + var doDeintH2645 = IsDeinterlaceAvailable(state); + var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value); + + // For filter chain containing the deinterlace filter, + // place the fps filter at the end to preserve temporal info. + if (doDeintH2645) + { + mainFilters.Add(fpsFilter); + } + else + { + mainFilters.Insert(0, fpsFilter); + } } var mainStr = string.Empty; @@ -7175,8 +7228,9 @@ namespace MediaBrowser.Controller.MediaEncoding && !IsCopyCodec(state.OutputVideoCodec) && options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio; + TranscodeReason audioCopyFailureReasons = 0; if (state.AudioStream is not null - && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs) + && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs, out audioCopyFailureReasons) && !preventHlsAudioCopy) { state.OutputAudioCodec = "copy"; @@ -7190,6 +7244,13 @@ namespace MediaBrowser.Controller.MediaEncoding { state.OutputAudioCodec = "copy"; } + else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec)) + { + // Audio is actually being re-encoded although the playback determination may have considered the source copyable. + // Only carry the primary "cannot be passed through" cause - the codec mismatch. + // Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target. + state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported); + } } } @@ -7809,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); } - if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) + var sampleRate = state.OutputAudioSampleRate; + if (sampleRate.HasValue) { - // opus only supports specific sampling rates - var sampleRate = state.OutputAudioSampleRate; - if (sampleRate.HasValue) + var sampleRateValue = sampleRate.Value; + if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) { - var sampleRateValue = sampleRate.Value switch + // opus only supports specific sampling rates + sampleRateValue = sampleRate.Value switch { <= 8000 => 8000, <= 12000 => 12000, @@ -7823,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding <= 24000 => 24000, _ => 48000 }; - - audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } + + audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } // Copy the movflags from GetProgressiveVideoFullCommandLine @@ -7886,6 +7948,14 @@ namespace MediaBrowser.Controller.MediaEncoding || (state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding && !IsCopyCodec(state.OutputVideoCodec)); } + private static bool NeedsExternalSubtitleMuxing(EncodingJobInfo state) + { + return state.SubtitleStream is not null + && state.SubtitleStream.IsExternal + && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed + || (ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream)); + } + public static string GetVideoSyncOption(string videoSync, Version encoderVersion) { if (string.IsNullOrEmpty(videoSync)) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 3a1897a244..314cd32903 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -515,6 +515,15 @@ namespace MediaBrowser.Controller.MediaEncoding public int HlsListSize => 0; + /// <summary> + /// Adds the specified reason(s) to <see cref="TranscodeReasons"/>. + /// </summary> + /// <param name="reason">The transcode reason(s) to add.</param> + public void AddTranscodeReason(TranscodeReason reason) + { + _transcodeReasons = TranscodeReasons | reason; + } + private int? GetMediaStreamCount(MediaStreamType type, int limit) { var count = MediaSource.GetStreamCount(type); diff --git a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs index 54da218530..9bee653e2e 100644 --- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs +++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs index d0cddf54a6..a4614fc125 100644 --- a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.Audio; using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; @@ -29,8 +30,9 @@ public interface ILinkedChildrenService /// Gets parent IDs that reference the specified child with LinkedChildType.Manual. /// </summary> /// <param name="childId">The child item ID.</param> + /// <param name="parentType">Optional parent item type filter.</param> /// <returns>List of parent IDs that reference the child.</returns> - IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId); + IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null); /// <summary> /// Updates LinkedChildren references from one child to another. diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index a89f3ef9ee..e2833dc722 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -32,4 +32,12 @@ public interface IPeopleRepository /// <param name="filter">The query.</param> /// <returns>The list of people names matching the filter.</returns> IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter); + + /// <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); } diff --git a/MediaBrowser.Controller/Plugins/IHasEmbeddedImage.cs b/MediaBrowser.Controller/Plugins/IHasEmbeddedImage.cs new file mode 100644 index 0000000000..4196cd9f24 --- /dev/null +++ b/MediaBrowser.Controller/Plugins/IHasEmbeddedImage.cs @@ -0,0 +1,17 @@ +namespace MediaBrowser.Controller.Plugins; + +/// <summary> +/// Marker interface for integrated/bundled plugins that ship their plugin image as an embedded +/// resource inside the plugin assembly rather than as a file on disk. +/// </summary> +/// <remarks> +/// This interface is intended for plugins compiled into the server. External plugins should +/// continue to declare their image via the <c>imagePath</c> field in <c>meta.json</c>. +/// </remarks> +public interface IHasEmbeddedImage +{ + /// <summary> + /// Gets the name of the embedded resource in this plugin's assembly to serve as the plugin image. + /// </summary> + string ImageResourceName { get; } +} diff --git a/MediaBrowser.Controller/Providers/BookInfo.cs b/MediaBrowser.Controller/Providers/BookInfo.cs index 3055c5d871..7f8151e534 100644 --- a/MediaBrowser.Controller/Providers/BookInfo.cs +++ b/MediaBrowser.Controller/Providers/BookInfo.cs @@ -1,11 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for books. + /// </summary> public class BookInfo : ItemLookupInfo { + /// <summary> + /// Gets or sets the name of the series the book belongs to. + /// </summary> public string SeriesName { get; set; } } } diff --git a/MediaBrowser.Controller/Providers/BoxSetInfo.cs b/MediaBrowser.Controller/Providers/BoxSetInfo.cs index f43ea67178..22dbdb959e 100644 --- a/MediaBrowser.Controller/Providers/BoxSetInfo.cs +++ b/MediaBrowser.Controller/Providers/BoxSetInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for box sets. + /// </summary> public class BoxSetInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs index 42cb523713..834e173ca3 100644 --- a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs @@ -1,10 +1,16 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Interface for items that provide lookup info for metadata providers. + /// </summary> + /// <typeparam name="TLookupInfoType">The type of the lookup info.</typeparam> public interface IHasLookupInfo<out TLookupInfoType> where TLookupInfoType : ItemLookupInfo, new() { + /// <summary> + /// Gets the lookup info. + /// </summary> + /// <returns>The lookup info.</returns> TLookupInfoType GetLookupInfo(); } } diff --git a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs index 6d98af33e4..668160759f 100644 --- a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs +++ b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Marker interface for custom metadata providers that run before the regular metadata refresh. + /// </summary> public interface IPreRefreshProvider : ICustomMetadataProvider { } diff --git a/MediaBrowser.Controller/Providers/MovieInfo.cs b/MediaBrowser.Controller/Providers/MovieInfo.cs index 20e6b697ad..a33f8bbfe2 100644 --- a/MediaBrowser.Controller/Providers/MovieInfo.cs +++ b/MediaBrowser.Controller/Providers/MovieInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for movies. + /// </summary> public class MovieInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs index 11cb71f902..d0eb5cb825 100644 --- a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs +++ b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for persons. + /// </summary> public class PersonLookupInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/SeriesInfo.cs b/MediaBrowser.Controller/Providers/SeriesInfo.cs index 976fa175ad..5ca5f0a534 100644 --- a/MediaBrowser.Controller/Providers/SeriesInfo.cs +++ b/MediaBrowser.Controller/Providers/SeriesInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for series. + /// </summary> public class SeriesInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Providers/TrailerInfo.cs b/MediaBrowser.Controller/Providers/TrailerInfo.cs index 630850f9db..c30468db6c 100644 --- a/MediaBrowser.Controller/Providers/TrailerInfo.cs +++ b/MediaBrowser.Controller/Providers/TrailerInfo.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// The lookup info for trailers. + /// </summary> public class TrailerInfo : ItemLookupInfo { } diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index 96783f6073..fb68bfb770 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -45,7 +45,6 @@ namespace MediaBrowser.Controller.Session PlayState = new PlayerStateInfo(); SessionControllers = []; NowPlayingQueue = []; - NowPlayingQueueFullItems = []; } /// <summary> @@ -272,15 +271,9 @@ namespace MediaBrowser.Controller.Session public IReadOnlyList<QueueItem> NowPlayingQueue { get; set; } /// <summary> - /// Gets or sets the now playing queue full items. - /// </summary> - /// <value>The now playing queue full items.</value> - public IReadOnlyList<BaseItemDto> NowPlayingQueueFullItems { get; set; } - - /// <summary> /// Gets or sets a value indicating whether the session has a custom device name. /// </summary> - /// <value><c>true</c> if this session has a custom device name; otherwise, <c>false</c>.</value> + /// <value><c>true</c> if the session has a custom device name; otherwise, <c>false</c>.</value> public bool HasCustomDeviceName { get; set; } /// <summary> diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index 132765b719..eb38eeb503 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -141,7 +141,8 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); // Ignore request and return to previous state. - IGroupState newState = prevState switch { + IGroupState newState = prevState switch + { GroupStateType.Playing => new PlayingGroupState(LoggerFactory), GroupStateType.Paused => new PausedGroupState(LoggerFactory), _ => new IdleGroupState(LoggerFactory) diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs index f56bc71d0d..599c6cd64f 100644 --- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs @@ -84,7 +84,7 @@ namespace MediaBrowser.LocalMetadata.Images if (item.SupportsLocalMetadata) { // Episode has its own provider - if (item is Episode || item is Audio || item is Photo) + if (item is Episode || (item is Audio && item is not AudioBook) || item is Photo) { return false; } diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index cf1423d02d..340d9843ff 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -143,16 +143,16 @@ namespace MediaBrowser.LocalMetadata.Parsers item.Name = reader.ReadNormalizedString(); break; case "CriticRating": - { - var text = reader.ReadElementContentAsString(); - - if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) { - item.CriticRating = value; - } + var text = reader.ReadElementContentAsString(); - break; - } + if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) + { + item.CriticRating = value; + } + + break; + } case "SortTitle": item.ForcedSortName = reader.ReadNormalizedString(); @@ -176,55 +176,55 @@ namespace MediaBrowser.LocalMetadata.Parsers break; case "LockedFields": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) { - item.LockedFields = val.Split('|').Select(i => + var val = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(val)) { - if (Enum.TryParse(i, true, out MetadataField field)) + item.LockedFields = val.Split('|').Select(i => { - return (MetadataField?)field; - } + if (Enum.TryParse(i, true, out MetadataField field)) + { + return (MetadataField?)field; + } - return null; - }).Where(i => i.HasValue).Select(i => i!.Value).ToArray(); - } + return null; + }).Where(i => i.HasValue).Select(i => i!.Value).ToArray(); + } - break; - } + break; + } case "TagLines": - { - if (!reader.IsEmptyElement) { - using (var subtree = reader.ReadSubtree()) + if (!reader.IsEmptyElement) { - FetchFromTaglinesNode(subtree, item); + using (var subtree = reader.ReadSubtree()) + { + FetchFromTaglinesNode(subtree, item); + } + } + else + { + reader.Read(); } - } - else - { - reader.Read(); - } - break; - } + break; + } case "Countries": - { - if (!reader.IsEmptyElement) - { - reader.Skip(); - } - else { - reader.Read(); - } + if (!reader.IsEmptyElement) + { + reader.Skip(); + } + else + { + reader.Read(); + } - break; - } + break; + } case "ContentRating": case "MPAARating": @@ -307,19 +307,19 @@ namespace MediaBrowser.LocalMetadata.Parsers break; case "Trailers": - { - if (!reader.IsEmptyElement) - { - using var subtree = reader.ReadSubtree(); - FetchDataFromTrailersNode(subtree, item); - } - else { - reader.Read(); - } + if (!reader.IsEmptyElement) + { + using var subtree = reader.ReadSubtree(); + FetchDataFromTrailersNode(subtree, item); + } + else + { + reader.Read(); + } - break; - } + break; + } case "ProductionYear": if (reader.TryReadInt(out var productionYear) && productionYear > 1850) @@ -330,20 +330,20 @@ namespace MediaBrowser.LocalMetadata.Parsers break; case "Rating": case "IMDBrating": - { - var rating = reader.ReadNormalizedString(); - - if (!string.IsNullOrEmpty(rating)) { - // All external meta is saving this as '.' for decimal I believe...but just to be sure - if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val)) + var rating = reader.ReadNormalizedString(); + + if (!string.IsNullOrEmpty(rating)) { - item.CommunityRating = val; + // All external meta is saving this as '.' for decimal I believe...but just to be sure + if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val)) + { + item.CommunityRating = val; + } } - } - break; - } + break; + } case "BirthDate": case "PremiereDate": @@ -370,144 +370,144 @@ namespace MediaBrowser.LocalMetadata.Parsers break; case "Genres": - { - if (!reader.IsEmptyElement) - { - using var subtree = reader.ReadSubtree(); - FetchFromGenresNode(subtree, item); - } - else { - reader.Read(); - } + if (!reader.IsEmptyElement) + { + using var subtree = reader.ReadSubtree(); + FetchFromGenresNode(subtree, item); + } + else + { + reader.Read(); + } - break; - } + break; + } case "Tags": - { - if (!reader.IsEmptyElement) - { - using var subtree = reader.ReadSubtree(); - FetchFromTagsNode(subtree, item); - } - else { - reader.Read(); - } + if (!reader.IsEmptyElement) + { + using var subtree = reader.ReadSubtree(); + FetchFromTagsNode(subtree, item); + } + else + { + reader.Read(); + } - break; - } + break; + } case "Persons": - { - if (!reader.IsEmptyElement) - { - using var subtree = reader.ReadSubtree(); - FetchDataFromPersonsNode(subtree, itemResult); - } - else { - reader.Read(); - } + if (!reader.IsEmptyElement) + { + using var subtree = reader.ReadSubtree(); + FetchDataFromPersonsNode(subtree, itemResult); + } + else + { + reader.Read(); + } - break; - } + break; + } case "Studios": - { - if (!reader.IsEmptyElement) - { - using var subtree = reader.ReadSubtree(); - FetchFromStudiosNode(subtree, item); - } - else { - reader.Read(); - } + if (!reader.IsEmptyElement) + { + using var subtree = reader.ReadSubtree(); + FetchFromStudiosNode(subtree, item); + } + else + { + reader.Read(); + } - break; - } + break; + } case "Shares": - { - if (!reader.IsEmptyElement) { - using var subtree = reader.ReadSubtree(); - if (item is IHasShares hasShares) + if (!reader.IsEmptyElement) { - FetchFromSharesNode(subtree, hasShares); + using var subtree = reader.ReadSubtree(); + if (item is IHasShares hasShares) + { + FetchFromSharesNode(subtree, hasShares); + } + } + else + { + reader.Read(); } - } - else - { - reader.Read(); - } - break; - } + break; + } case "OwnerUserId": - { - var val = reader.ReadNormalizedString(); - - if (Guid.TryParse(val, out var guid) && !guid.Equals(Guid.Empty)) { - if (item is Playlist playlist) + var val = reader.ReadNormalizedString(); + + if (Guid.TryParse(val, out var guid) && !guid.Equals(Guid.Empty)) { - playlist.OwnerUserId = guid; + if (item is Playlist playlist) + { + playlist.OwnerUserId = guid; + } } - } - break; - } + break; + } case "Format3D": - { - var val = reader.ReadNormalizedString(); - - if (item is Video video) { - if (string.Equals("HSBS", val, StringComparison.OrdinalIgnoreCase)) - { - video.Video3DFormat = Video3DFormat.HalfSideBySide; - } - else if (string.Equals("HTAB", val, StringComparison.OrdinalIgnoreCase)) - { - video.Video3DFormat = Video3DFormat.HalfTopAndBottom; - } - else if (string.Equals("FTAB", val, StringComparison.OrdinalIgnoreCase)) + var val = reader.ReadNormalizedString(); + + if (item is Video video) { - video.Video3DFormat = Video3DFormat.FullTopAndBottom; + if (string.Equals("HSBS", val, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.HalfSideBySide; + } + else if (string.Equals("HTAB", val, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.HalfTopAndBottom; + } + else if (string.Equals("FTAB", val, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.FullTopAndBottom; + } + else if (string.Equals("FSBS", val, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.FullSideBySide; + } + else if (string.Equals("MVC", val, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.MVC; + } } - else if (string.Equals("FSBS", val, StringComparison.OrdinalIgnoreCase)) + + break; + } + + default: + { + string readerName = reader.Name; + if (_validProviderIds!.TryGetValue(readerName, out string? providerIdValue)) { - video.Video3DFormat = Video3DFormat.FullSideBySide; + var id = reader.ReadNormalizedString(); + item.TrySetProviderId(providerIdValue, id); } - else if (string.Equals("MVC", val, StringComparison.OrdinalIgnoreCase)) + else { - video.Video3DFormat = Video3DFormat.MVC; + reader.Skip(); } - } - break; - } - - default: - { - string readerName = reader.Name; - if (_validProviderIds!.TryGetValue(readerName, out string? providerIdValue)) - { - var id = reader.ReadNormalizedString(); - item.TrySetProviderId(providerIdValue, id); + break; } - else - { - reader.Skip(); - } - - break; - } } } @@ -526,31 +526,31 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Share": - { - if (reader.IsEmptyElement) { - reader.Read(); - continue; - } - - using (var subReader = reader.ReadSubtree()) - { - var child = GetShare(subReader); + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } - if (child is not null) + using (var subReader = reader.ReadSubtree()) { - list.Add(child); + var child = GetShare(subReader); + + if (child is not null) + { + list.Add(child); + } } - } - break; - } + break; + } default: - { - reader.Skip(); - break; - } + { + reader.Skip(); + break; + } } } else diff --git a/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs index 00634de5b5..324505d17c 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BoxSetXmlParser.cs @@ -64,32 +64,32 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "CollectionItem": - { - if (!reader.IsEmptyElement) { - using (var subReader = reader.ReadSubtree()) + if (!reader.IsEmptyElement) { - var child = GetLinkedChild(subReader); - - if (child is not null) + using (var subReader = reader.ReadSubtree()) { - list.Add(child); + var child = GetLinkedChild(subReader); + + if (child is not null) + { + list.Add(child); + } } } - } - else - { - reader.Read(); - } + else + { + reader.Read(); + } - break; - } + break; + } default: - { - reader.Skip(); - break; - } + { + reader.Skip(); + break; + } } } else diff --git a/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs index e0277870d1..0bda9e300a 100644 --- a/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs @@ -76,25 +76,25 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "PlaylistItem": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using (var subReader = reader.ReadSubtree()) { - var child = GetLinkedChild(subReader); + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } - if (child is not null) + using (var subReader = reader.ReadSubtree()) { - list.Add(child); + var child = GetLinkedChild(subReader); + + if (child is not null) + { + list.Add(child); + } } - } - break; - } + break; + } default: reader.Skip(); diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 7f40f4fd3e..9dd3dcecba 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -102,13 +104,10 @@ namespace MediaBrowser.MediaEncoding.Attachments && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - foreach (var attachment in mediaSource.MediaAttachments) - { - if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) - { - await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false); - } - } + await ExtractAllAttachmentsIndividuallyInternal( + inputFile, + mediaSource, + cancellationToken).ConfigureAwait(false); } else { @@ -119,6 +118,140 @@ namespace MediaBrowser.MediaEncoding.Attachments } } + private async Task ExtractAllAttachmentsIndividuallyInternal( + string inputFile, + MediaSourceInfo mediaSource, + CancellationToken cancellationToken) + { + var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource); + + ArgumentException.ThrowIfNullOrEmpty(inputPath); + + var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + if (outputFolder is null) + { + _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile); + return; + } + + using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false)) + { + Directory.CreateDirectory(outputFolder); + + var dumpArgs = new StringBuilder(); + var missingPaths = new List<string>(); + foreach (var attachment in mediaSource.MediaAttachments) + { + if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName) + ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!; + if (File.Exists(attachmentPath)) + { + continue; + } + + dumpArgs.AppendFormat( + CultureInfo.InvariantCulture, + "-dump_attachment:{0} \"{1}\" ", + attachment.Index, + EncodingUtils.NormalizePath(attachmentPath)); + missingPaths.Add(attachmentPath); + } + + if (missingPaths.Count == 0) + { + // Skip extraction if all files already exist + return; + } + + var hasVideoOrAudioStream = mediaSource.MediaStreams + .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio); + var processArgs = string.Format( + CultureInfo.InvariantCulture, + "{0}{1} -i {2} {3}", + dumpArgs, + inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty, + inputPath, + hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); + + int exitCode; + + using (var process = new Process + { + StartInfo = new ProcessStartInfo + { + Arguments = processArgs, + FileName = _mediaEncoder.EncoderPath, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + EnableRaisingEvents = true + }) + { + _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Start(); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; + } + } + + var failed = false; + + if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1)) + { + failed = true; + + foreach (var path in missingPaths) + { + if (!File.Exists(path)) + { + continue; + } + + try + { + _fileSystem.DeleteFile(path); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted attachment {Path}", path); + } + } + } + + if (!failed && missingPaths.Exists(p => !File.Exists(p))) + { + failed = true; + } + + if (failed) + { + _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder); + + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder)); + } + + _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder); + } + } + private async Task ExtractAllAttachmentsInternal( string inputFile, MediaSourceInfo mediaSource, @@ -163,19 +296,19 @@ namespace MediaBrowser.MediaEncoding.Attachments int exitCode; using (var process = new Process + { + StartInfo = new ProcessStartInfo { - StartInfo = new ProcessStartInfo - { - Arguments = processArgs, - FileName = _mediaEncoder.EncoderPath, - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, - WorkingDirectory = outputFolder, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) + Arguments = processArgs, + FileName = _mediaEncoder.EncoderPath, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = outputFolder, + ErrorDialog = false + }, + EnableRaisingEvents = true + }) { _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); @@ -295,18 +428,18 @@ namespace MediaBrowser.MediaEncoding.Attachments int exitCode; using (var process = new Process + { + StartInfo = new ProcessStartInfo { - StartInfo = new ProcessStartInfo - { - Arguments = processArgs, - FileName = _mediaEncoder.EncoderPath, - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) + Arguments = processArgs, + FileName = _mediaEncoder.EncoderPath, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + EnableRaisingEvents = true + }) { _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); diff --git a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs index a8ff58b091..baea0df8cc 100644 --- a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs +++ b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs @@ -1,9 +1,11 @@ #pragma warning disable CA1031 using System; +using System.Buffers; using System.Linq; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Text; using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Encoder; @@ -12,43 +14,43 @@ namespace MediaBrowser.MediaEncoding.Encoder; /// Helper class for Apple platform specific operations. /// </summary> [SupportedOSPlatform("macos")] -public static class ApplePlatformHelper +public static partial class ApplePlatformHelper { private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"]; - private static string GetSysctlValue(ReadOnlySpan<byte> name) + internal static string GetSysctlValue(string name) { - IntPtr length = IntPtr.Zero; + nuint length = 0; // Get length of the value - int osStatus = SysctlByName(name, IntPtr.Zero, ref length, IntPtr.Zero, 0); - - if (osStatus != 0) + int osStatus = sysctlbyname(name, Span<byte>.Empty, ref length, IntPtr.Zero, 0); + if (osStatus != 0 || length == 0) { - throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}"); + throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}"); } - IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32()); + byte[] buffer = ArrayPool<byte>.Shared.Rent((int)length); try { - osStatus = SysctlByName(name, buffer, ref length, IntPtr.Zero, 0); + osStatus = sysctlbyname(name, buffer.AsSpan()[..(int)length], ref length, IntPtr.Zero, 0); if (osStatus != 0) { - throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}"); + throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}"); } - return Marshal.PtrToStringAnsi(buffer) ?? string.Empty; + if (length < 1) + { + return string.Empty; + } + + ReadOnlySpan<byte> data = buffer.AsSpan()[..(int)(length - 1)]; + return Encoding.UTF8.GetString(data); } finally { - Marshal.FreeHGlobal(buffer); + ArrayPool<byte>.Shared.Return(buffer); } } - private static int SysctlByName(ReadOnlySpan<byte> name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen) - { - return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen); - } - /// <summary> /// Check if the current system has hardware acceleration for AV1 decoding. /// </summary> @@ -63,7 +65,7 @@ public static class ApplePlatformHelper try { - string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8); + string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"); return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase)); } catch (NotSupportedException e) @@ -78,10 +80,7 @@ public static class ApplePlatformHelper return false; } - private static class NativeMethods - { - [DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)] - [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] - internal static extern int SysctlByName(byte[] name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen); - } + [LibraryImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] + internal static partial int sysctlbyname([MarshalAs(UnmanagedType.LPStr)] string name, Span<byte> oldp, ref nuint oldlenp, IntPtr newp, nuint newlen); } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 68d6d215b2..1f84b46a2b 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Globalization; using System.Linq; using System.Runtime.Versioning; +using System.Text; using System.Text.RegularExpressions; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; @@ -184,8 +185,7 @@ 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; @@ -645,7 +645,9 @@ namespace MediaBrowser.MediaEncoding.Encoder WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false, RedirectStandardInput = redirectStandardIn, + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true } }) diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 66bf6ebd24..0ddd378352 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; @@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder UseShellExecute = false, // Must consume both or ffmpeg may hang due to deadlocks. + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, FileName = _ffprobePath, @@ -926,6 +928,25 @@ namespace MediaBrowser.MediaEncoding.Encoder 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( diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index fc11047a7f..288cd3e189 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -9,6 +9,7 @@ <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> <ItemGroup> diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index 975c2b8161..fa2085ca6f 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -76,7 +76,13 @@ namespace MediaBrowser.MediaEncoding.Probing /// <returns>Dictionary{System.StringSystem.String}.</returns> private static Dictionary<string, string?> ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary<string, string?> dict) { - return new Dictionary<string, string?>(dict, StringComparer.OrdinalIgnoreCase); + var result = new Dictionary<string, string?>(dict.Count, StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in dict) + { + result.TryAdd(key, value); + } + + return result; } } } diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 06060988e2..203e565ac7 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing { if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue) { - mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Channels); + mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Profile, mediaStream.Channels); } } - var videoStreamsBitrate = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).Select(i => i.BitRate ?? 0).Sum(); - // If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong - if (videoStreamsBitrate == (info.Bitrate ?? 0)) + // ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers). + // Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates. + var videoStreams = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).ToList(); + if (info.Bitrate.HasValue + && videoStreams.Count == 1 + && !videoStreams[0].BitRate.HasValue) { - info.InferTotalBitrate(true); + var otherStreams = info.MediaStreams + .Where(i => i.Type != MediaStreamType.Video && !i.IsExternal) + .ToList(); + + // Only attribute the leftover bitrate to the video stream if every audio stream's bitrate is known. + var audioBitratesKnown = otherStreams + .Where(i => i.Type == MediaStreamType.Audio) + .All(i => i.BitRate.HasValue); + + if (audioBitratesKnown) + { + var estimatedVideoBitrate = info.Bitrate.Value - otherStreams.Sum(i => i.BitRate ?? 0); + if (estimatedVideoBitrate > 0) + { + videoStreams[0].BitRate = estimatedVideoBitrate; + } + } } + + // If the container bitrate is still unknown, infer it from the sum of the streams. + info.InferTotalBitrate(); } return info; @@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s))); } - private static int? GetEstimatedAudioBitrate(string codec, int? channels) + internal static int? GetEstimatedAudioBitrate(string codec, string profile, int? channels) { - if (!channels.HasValue) + if (!channels.HasValue || channels.Value < 1 || string.IsNullOrEmpty(codec)) { return null; } - var channelsValue = channels.Value; - - if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) - { - switch (channelsValue) - { - case <= 2: - return 192000; - case >= 5: - return 320000; - } - } - - if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) - { - switch (channelsValue) - { - case <= 2: - return 192000; - case >= 5: - return 640000; - } - } + // Rough typical bitrates used only as a fallback when ffprobe doesn't report a stream bitrate. + var channelCount = channels.Value; + var isMultichannel = channelCount > 2; - if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) + return codec.ToLowerInvariant() switch { - switch (channelsValue) - { - case <= 2: - return 960000; - case >= 5: - return 2880000; - } - } - - return null; + "aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000, + "ac3" or "eac3" => isMultichannel ? 640000 : 192000, + "dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000), + "opus" => isMultichannel ? 256000 : 128000, + "vorbis" => isMultichannel ? 320000 : 160000, + "wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000, + "flac" or "alac" => channelCount * 480000, + "truehd" or "mlp" => channelCount * 700000, + _ => null + }; } + private static bool IsDtsLossless(string profile) + => profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase); + private void FetchFromItunesInfo(string xml, MediaInfo info) { // Make things simpler and strip out the dtd @@ -972,10 +974,12 @@ namespace MediaBrowser.MediaEncoding.Probing bitrate = value; } - // The bitrate info of FLAC musics and some videos is included in formatInfo. + // The bitrate info of FLAC audio is included in formatInfo. + // Don't do this for video streams: formatInfo.BitRate is the overall container + // bitrate (video + audio + subtitles + overhead), not the video bitrate. if (bitrate == 0 && formatInfo is not null - && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio))) + && isAudio && stream.Type == MediaStreamType.Audio) { // If the stream info doesn't have a bitrate get the value from the media format info if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value)) @@ -1260,9 +1264,16 @@ namespace MediaBrowser.MediaEncoding.Probing } var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); - if (TimeSpan.TryParse(duration, out var parsedDuration)) + if (!string.IsNullOrEmpty(duration)) { - return parsedDuration.TotalSeconds; + // Matroska DURATION tags use nanosecond precision (e.g. "00:00:05.023000000"), but + // TimeSpan only supports up to 7 fractional digits (ticks). Trim the surplus digits so + // these durations parse instead of being silently dropped. + duration = DurationOverPrecisionRegex().Replace(duration, "$1"); + if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsedDuration)) + { + return parsedDuration.TotalSeconds; + } } return null; @@ -1630,7 +1641,7 @@ namespace MediaBrowser.MediaEncoding.Probing // Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/ // DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None) - if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate)) + if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsedDate)) { video.PremiereDate = parsedDate; } @@ -1764,5 +1775,8 @@ namespace MediaBrowser.MediaEncoding.Probing [GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")] private static partial Regex PerformerRegex(); + + [GeneratedRegex(@"(\.\d{7})\d+")] + private static partial Regex DurationOverPrecisionRegex(); } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs deleted file mode 100644 index 7d7b80e99d..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// <summary> - /// ASS subtitle writer. - /// </summary> - public partial class AssWriter : ISubtitleWriter - { - [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineRegex(); - - /// <inheritdoc /> - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - var trackEvents = info.TrackEvents; - var timeFormat = @"hh\:mm\:ss\.ff"; - - // Write ASS header - writer.WriteLine("[Script Info]"); - writer.WriteLine("Title: Jellyfin transcoded ASS subtitle"); - writer.WriteLine("ScriptType: v4.00+"); - writer.WriteLine(); - writer.WriteLine("[V4+ Styles]"); - writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"); - writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H910E0807,0,0,0,0,100,100,0,0,0,1,0,2,10,10,10,1"); - writer.WriteLine(); - writer.WriteLine("[Events]"); - writer.WriteLine("Format: Layer, Start, End, Style, Text"); - - for (int i = 0; i < trackEvents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var trackEvent = trackEvents[i]; - var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var text = NewLineRegex().Replace(trackEvent.Text, "\\n"); - - writer.WriteLine( - "Dialogue: 0,{0},{1},Default,{2}", - startTime, - endTime, - text); - } - } - } - } -} 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/ISubtitleWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs deleted file mode 100644 index dec714121d..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.IO; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// <summary> - /// Interface ISubtitleWriter. - /// </summary> - public interface ISubtitleWriter - { - /// <summary> - /// Writes the specified information. - /// </summary> - /// <param name="info">The information.</param> - /// <param name="stream">The stream.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs index 1b452b0cec..0e40181016 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs @@ -1,44 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Text; using System.Text.Json; -using System.Threading; -using MediaBrowser.Model.MediaInfo; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; -namespace MediaBrowser.MediaEncoding.Subtitles +namespace MediaBrowser.MediaEncoding.Subtitles; + +/// <summary> +/// JSON subtitle writer. +/// </summary> +public class JsonWriter : SubtitleFormat { - /// <summary> - /// JSON subtitle writer. - /// </summary> - public class JsonWriter : ISubtitleWriter + /// <inheritdoc /> + public override string Extension => ".json"; + + /// <inheritdoc /> + public override string Name => "JSON Jellyfin"; + + /// <inheritdoc /> + public override string ToText(Subtitle subtitle, string title) { - /// <inheritdoc /> - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms)) { - using (var writer = new Utf8JsonWriter(stream)) + var trackevents = subtitle.Paragraphs; + writer.WriteStartObject(); + writer.WriteStartArray("TrackEvents"); + + for (int i = 0; i < trackevents.Count; i++) { - var trackevents = info.TrackEvents; + var current = trackevents[i]; writer.WriteStartObject(); - writer.WriteStartArray("TrackEvents"); - - for (int i = 0; i < trackevents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var current = trackevents[i]; - writer.WriteStartObject(); - writer.WriteString("Id", current.Id); - writer.WriteString("Text", current.Text); - writer.WriteNumber("StartPositionTicks", current.StartPositionTicks); - writer.WriteNumber("EndPositionTicks", current.EndPositionTicks); + writer.WriteString("Id", current.Number.ToString(CultureInfo.InvariantCulture)); + writer.WriteString("Text", current.Text); + writer.WriteNumber("StartPositionTicks", current.StartTime.TimeSpan.Ticks); + writer.WriteNumber("EndPositionTicks", current.EndTime.TimeSpan.Ticks); - writer.WriteEndObject(); - } - - writer.WriteEndArray(); writer.WriteEndObject(); - - writer.Flush(); } + + writer.WriteEndArray(); + writer.WriteEndObject(); + + writer.Flush(); } + + return Encoding.UTF8.GetString(ms.GetBuffer(), 0, (int)ms.Length); } + + /// <inheritdoc /> + public override void LoadSubtitle(Subtitle subtitle, List<string> lines, string fileName) + => throw new NotImplementedException(); } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs deleted file mode 100644 index 86f77aa067..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// <summary> - /// SRT subtitle writer. - /// </summary> - public partial class SrtWriter : ISubtitleWriter - { - [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineEscapedRegex(); - - /// <inheritdoc /> - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - var trackEvents = info.TrackEvents; - - for (int i = 0; i < trackEvents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var trackEvent = trackEvents[i]; - - writer.WriteLine((i + 1).ToString(CultureInfo.InvariantCulture)); - writer.WriteLine( - @"{0:hh\:mm\:ss\,fff} --> {1:hh\:mm\:ss\,fff}", - TimeSpan.FromTicks(trackEvent.StartPositionTicks), - TimeSpan.FromTicks(trackEvent.EndPositionTicks)); - - var text = trackEvent.Text; - - // TODO: Not sure how to handle these - text = NewLineEscapedRegex().Replace(text, " "); - - writer.WriteLine(text); - writer.WriteLine(); - } - } - } - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs deleted file mode 100644 index b5fd1ed935..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// <summary> - /// SSA subtitle writer. - /// </summary> - public partial class SsaWriter : ISubtitleWriter - { - [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineRegex(); - - /// <inheritdoc /> - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - var trackEvents = info.TrackEvents; - var timeFormat = @"hh\:mm\:ss\.ff"; - - // Write SSA header - writer.WriteLine("[Script Info]"); - writer.WriteLine("Title: Jellyfin transcoded SSA subtitle"); - writer.WriteLine("ScriptType: v4.00"); - writer.WriteLine(); - writer.WriteLine("[V4 Styles]"); - writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding"); - writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H19333333,0,0,0,1,0,2,10,10,10,0,1"); - writer.WriteLine(); - writer.WriteLine("[Events]"); - writer.WriteLine("Format: Layer, Start, End, Style, Text"); - - for (int i = 0; i < trackEvents.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var trackEvent = trackEvents[i]; - var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var text = NewLineRegex().Replace(trackEvent.Text, "\\n"); - - writer.WriteLine( - "Dialogue: 0,{0},{1},Default,{2}", - startTime, - endTime, - text); - } - } - } - } -} 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 8ad66fce40..c568a74b01 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -26,7 +26,10 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; using UtfUnknown; +using SubtitleFormat = MediaBrowser.Model.MediaInfo.SubtitleFormat; namespace MediaBrowser.MediaEncoding.Subtitles { @@ -70,57 +73,44 @@ namespace MediaBrowser.MediaEncoding.Subtitles _serverConfigurationManager = serverConfigurationManager; } - private MemoryStream ConvertSubtitles( + internal MemoryStream ConvertSubtitles( Stream stream, - string inputFormat, + SubtitleInfo inputInfo, string outputFormat, long startTimeTicks, long endTimeTicks, - bool preserveOriginalTimestamps, - CancellationToken cancellationToken) + bool preserveOriginalTimestamps) { - var ms = new MemoryStream(); - - try - { - var trackInfo = _subtitleParser.Parse(stream, inputFormat); + var subtitle = _subtitleParser.Parse(stream, inputInfo.Format); - FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); + FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); - var writer = GetWriter(outputFormat); + var formatter = GetWriter(outputFormat); - writer.Write(trackInfo, ms, cancellationToken); - ms.Position = 0; - } - catch - { - ms.Dispose(); - throw; - } + var text = formatter.ToText(subtitle, "untitled"); + var bytes = Encoding.UTF8.GetBytes(text); - return ms; + return new MemoryStream(bytes, 0, bytes.Length, false, true); } - internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) + internal void FilterEvents(Subtitle track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) { // Drop subs that have fully elapsed before the requested start position - track.TrackEvents = track.TrackEvents - .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTicks) < 0) - .ToArray(); + track.Paragraphs + .RemoveAll(i => (i.StartTime.TimeSpan.Ticks - startPositionTicks) < 0 && (i.EndTime.TimeSpan.Ticks - startPositionTicks) < 0); if (endTimeTicks > 0) { - track.TrackEvents = track.TrackEvents - .TakeWhile(i => i.StartPositionTicks <= endTimeTicks) - .ToArray(); + track.Paragraphs + .RemoveAll(i => i.StartTime.TimeSpan.Ticks > endTimeTicks); } if (!preserveTimestamps) { - foreach (var trackEvent in track.TrackEvents) + foreach (var trackEvent in track.Paragraphs) { - trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks); - trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks); + trackEvent.StartTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.StartTime.TimeSpan.Ticks - startPositionTicks))); + trackEvent.EndTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.EndTime.TimeSpan.Ticks - startPositionTicks))); } } } @@ -142,14 +132,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles var subtitleStream = mediaSource.MediaStreams .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex); - var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) + var (stream, info) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) .ConfigureAwait(false); // Return the original if the same format is being requested // Character encoding was already handled in GetSubtitleStream // ASS is a superset of SSA, skipping the conversion and preserving the styles - if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase) - || (string.Equals(inputFormat, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase) + if (string.Equals(info.Format, outputFormat, StringComparison.OrdinalIgnoreCase) + || (string.Equals(info.Format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase) && string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))) { return stream; @@ -157,11 +147,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles using (stream) { - return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); + return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); } } - private async Task<(Stream Stream, string Format)> GetSubtitleStream( + private async Task<(Stream Stream, SubtitleInfo Info)> GetSubtitleStream( MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) @@ -170,14 +160,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false); - return (stream, fileInfo.Format); + return (stream, fileInfo); } private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { if (fileInfo.Protocol == MediaProtocol.Http) { - var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false); + var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false); var detected = result.Detected; if (detected is not null) @@ -190,10 +180,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles await using (stream.ConfigureAwait(false)) { - using var reader = new StreamReader(stream, detected.Encoding); - var text = await reader.ReadToEndAsync(cancellationToken).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)); } } } @@ -220,12 +210,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles Path = outputPath, Protocol = MediaProtocol.File, Format = outputFormat, - IsExternal = false + IsExternal = MediaStream.IsVobSubFormat(outputFormat) }; } - var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path) - .TrimStart('.'); + // Normalize ffmpeg codec names to the file extensions the parser is keyed on + var currentFormat = NormalizeCodecToParserExtension((Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec).TrimStart('.')); // Handle PGS subtitles as raw streams for the client to render if (MediaStream.IsPgsFormat(currentFormat)) @@ -267,13 +257,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles }; } - private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value) + private bool TryGetWriter(string format, [NotNullWhen(true)] out Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat? value) { ArgumentException.ThrowIfNullOrEmpty(format); if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)) { - value = new AssWriter(); + value = new AdvancedSubStationAlpha(); return true; } @@ -283,27 +273,29 @@ namespace MediaBrowser.MediaEncoding.Subtitles return true; } - if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) + || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase)) { - value = new SrtWriter(); + value = new SubRip(); return true; } if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)) { - value = new SsaWriter(); + value = new SubStationAlpha(); return true; } - if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) + || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) { - value = new VttWriter(); + value = new WebVTT(); return true; } if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase)) { - value = new TtmlWriter(); + value = new TimedText10(); return true; } @@ -311,7 +303,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles return false; } - private ISubtitleWriter GetWriter(string format) + private Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat GetWriter(string format) { if (TryGetWriter(format, out var writer)) { @@ -333,13 +325,91 @@ namespace MediaBrowser.MediaEncoding.Subtitles { using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { - if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0) + if (!IsCachedSubtitleFresh(outputPath, subtitleStream.Path)) { await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); } } } + // ffmpeg codec names don't always match the file extensions the subtitle parser is keyed on. + private static string NormalizeCodecToParserExtension(string codecOrExtension) + { + return codecOrExtension switch + { + "subrip" => "srt", + "webvtt" => "vtt", + _ => codecOrExtension + }; + } + + // Records "this cache was built from this exact source revision" in a sidecar file next to the cache: "<sizeBytes>:<mtimeTicks>" + private static string GetCacheMetaPath(string cachePath) => cachePath + ".meta"; + + private static string FormatCacheMeta(long length, DateTime lastWriteUtc) + => string.Create(CultureInfo.InvariantCulture, $"{length}:{lastWriteUtc.Ticks}"); + + private bool IsCachedSubtitleFresh(string cachePath, string? sourcePath) + { + if (!File.Exists(cachePath)) + { + return false; + } + + var cacheInfo = _fileSystem.GetFileInfo(cachePath); + if (cacheInfo.Length == 0) + { + return false; + } + + if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + { + return true; + } + + var metaPath = GetCacheMetaPath(cachePath); + if (!File.Exists(metaPath)) + { + // Pre-existing cache from before metadata tracking - regenerate so we can record the source state. + return false; + } + + try + { + var sourceInfo = _fileSystem.GetFileInfo(sourcePath); + var expected = FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc); + var actual = File.ReadAllText(metaPath); + return string.Equals(expected, actual, StringComparison.Ordinal); + } + catch (IOException) + { + return false; + } + } + + private void WriteCacheMeta(string cachePath, string? sourcePath) + { + if (string.IsNullOrEmpty(sourcePath)) + { + return; + } + + try + { + var sourceInfo = _fileSystem.GetFileInfo(sourcePath); + if (!sourceInfo.Exists) + { + return; + } + + File.WriteAllText(GetCacheMetaPath(cachePath), FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc)); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to record subtitle cache metadata for {CachePath}", cachePath); + } + } + /// <summary> /// Converts the text subtitle to SRT internal. /// </summary> @@ -375,96 +445,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - int exitCode; + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); - using (var process = new Process - { - StartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - FileName = _mediaEncoder.EncoderPath, - Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) - { - _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - - try - { - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting ffmpeg"); - - throw; - } - - try - { - var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; - await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false); - exitCode = process.ExitCode; - } - catch (OperationCanceledException) - { - process.Kill(true); - exitCode = -1; - } - } - - var failed = false; - - if (exitCode == -1) - { - failed = true; - - if (File.Exists(outputPath)) - { - try - { - _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath); - } - } - } - else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0) - { - failed = true; - - try - { - _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); - } - catch (FileNotFoundException) - { - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath); - } - } - - if (failed) - { - _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath); - - throw new FfmpegException( - string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath)); - } - - await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); + await ExtractSubtitlesForFile( + inputPath, + args, + [outputPath], + cancellationToken).ConfigureAwait(false); - _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); + WriteCacheMeta(outputPath, inputPath); } private string GetExtractableSubtitleFormat(MediaStream subtitleStream) @@ -475,6 +464,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles { return subtitleStream.Codec; } + else if (MediaStream.IsVobSubFormat(subtitleStream.Codec)) + { + return "mks"; + } else { return "srt"; @@ -488,6 +481,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles { return "sup"; } + else if (MediaStream.IsVobSubFormat(subtitleStream.Codec)) + { + // FFmpeg cannot mux VobSub subtitle streams back into the .idx/.sub pair, so we use .mks container instead. + return "mks"; + } else { return GetExtractableSubtitleFormat(subtitleStream); @@ -500,7 +498,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase); + || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase) + || MediaStream.IsVobSubFormat(codec); } /// <inheritdoc /> @@ -516,7 +515,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles foreach (var subtitleStream in subtitleStreams) { - if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) + if (subtitleStream.IsExternal + && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -529,7 +529,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false); - if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0) + var sourcePath = string.IsNullOrEmpty(subtitleStream.Path) ? mediaSource.Path : subtitleStream.Path; + if (IsCachedSubtitleFresh(outputPath, sourcePath)) { releaser.Dispose(); continue; @@ -586,7 +587,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputPaths = new List<string>(); var args = string.Format( CultureInfo.InvariantCulture, - "-i {0}", + "-y -i {0}", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -603,6 +604,8 @@ 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); if (streamIndex == -1) @@ -616,13 +619,19 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", + " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"", streamIndex, outputCodec, + outputFormatOption, outputPath); } await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); + + foreach (var outputPath in outputPaths) + { + WriteCacheMeta(outputPath, mksFile); + } } } @@ -635,7 +644,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputPaths = new List<string>(); var args = string.Format( CultureInfo.InvariantCulture, - "-i {0}", + "-y -i {0}", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -653,6 +662,8 @@ 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); if (streamIndex == -1) @@ -666,67 +677,31 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", + " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"", streamIndex, outputCodec, + outputFormatOption, outputPath); } - if (outputPaths.Count == 0) + if (outputPaths.Count > 0) { - return; - } + await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); - await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); + foreach (var outputPath in outputPaths) + { + WriteCacheMeta(outputPath, mediaSource.Path); + } + } } private async Task ExtractSubtitlesForFile( string inputPath, string args, - List<string> outputPaths, + IReadOnlyList<string> outputPaths, CancellationToken cancellationToken) { - int exitCode; - - using (var process = new Process - { - StartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - FileName = _mediaEncoder.EncoderPath, - Arguments = args, - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }, - EnableRaisingEvents = true - }) - { - _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); - - try - { - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting ffmpeg"); - - throw; - } - - try - { - var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; - await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false); - exitCode = process.ExitCode; - } - catch (OperationCanceledException) - { - process.Kill(true); - exitCode = -1; - } - } + var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false); var failed = false; @@ -786,6 +761,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (failed) { + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.IsNullOrWhiteSpace(ffmpegError)) + { + _logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError); + } + throw new FfmpegException( string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath)); } @@ -843,16 +825,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles ArgumentException.ThrowIfNullOrEmpty(outputPath); Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath))); - var processArgs = string.Format( CultureInfo.InvariantCulture, - "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", + "-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", inputPath, subtitleStreamIndex, outputCodec, outputPath); + await ExtractSubtitlesForFile( + inputPath, + processArgs, + [outputPath], + cancellationToken).ConfigureAwait(false); + } + + /// <summary> + /// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output. + /// </summary> + /// <remarks> + /// stdin is redirected and closed, and <c>-nostdin</c> is prepended to the arguments, so ffmpeg can never + /// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM, + /// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer + /// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is + /// unused for subtitle extraction. + /// </remarks> + /// <param name="arguments">The ffmpeg command line arguments.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns> + private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken) + { int exitCode; + var standardError = string.Empty; using (var process = new Process { @@ -860,8 +864,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles { CreateNoWindow = true, UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardError = true, FileName = _mediaEncoder.EncoderPath, - Arguments = processArgs, + Arguments = "-nostdin " + arguments, WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }, @@ -877,14 +883,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles catch (Exception ex) { _logger.LogError(ex, "Error starting ffmpeg"); - throw; } + // Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle. + process.StandardInput.Close(); + + // Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg. + var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None); + var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; + using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); + try { - var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes; - await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false); + await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false); exitCode = process.ExitCode; } catch (OperationCanceledException) @@ -892,59 +905,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles process.Kill(true); exitCode = -1; } - } - - var failed = false; - - if (exitCode == -1) - { - failed = true; try { - _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); + standardError = await standardErrorTask.ConfigureAwait(false); } - catch (FileNotFoundException) - { - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); - } - } - else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0) - { - failed = true; - - try - { - _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); - _fileSystem.DeleteFile(outputPath); - } - catch (FileNotFoundException) - { - } - catch (IOException ex) + catch (OperationCanceledException) { - _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); + // Reading ffmpeg output was cancelled; nothing more to capture. } } - if (failed) - { - _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); - - throw new FfmpegException( - string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath)); - } - - _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); - - if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase)) - { - await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); - } + return (exitCode, standardError); } /// <summary> @@ -1006,7 +978,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false); + var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false); var charset = result.Detected?.EncodingName ?? string.Empty; // UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding @@ -1022,28 +994,29 @@ namespace MediaBrowser.MediaEncoding.Subtitles return charset; } - private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken) + private async Task<DetectionResult> DetectCharset(string path, CancellationToken cancellationToken) { + var protocol = _mediaSourceManager.GetPathProtocol(path); switch (protocol) { case MediaProtocol.Http: - { - using var stream = await _httpClientFactory - .CreateClient(NamedClient.Default) - .GetStreamAsync(new Uri(path), cancellationToken) - .ConfigureAwait(false); + { + using var stream = await _httpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(new Uri(path), cancellationToken) + .ConfigureAwait(false); - return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); - } + return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false); + } case MediaProtocol.File: - { - return await CharsetDetector.DetectFromFileAsync(path, cancellationToken) - .ConfigureAwait(false); - } + { + return await CharsetDetector.DetectFromFileAsync(path, cancellationToken) + .ConfigureAwait(false); + } default: - throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol"); + throw new NotSupportedException($"Unsupported protocol: {protocol}"); } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs deleted file mode 100644 index ea45f2070a..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// <summary> - /// TTML subtitle writer. - /// </summary> - public partial class TtmlWriter : ISubtitleWriter - { - [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] - private static partial Regex NewLineEscapeRegex(); - - /// <inheritdoc /> - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - // Example: https://github.com/zmalltalker/ttml2vtt/blob/master/data/sample.xml - // Parser example: https://github.com/mozilla/popcorn-js/blob/master/parsers/parserTTML/popcorn.parserTTML.js - - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - writer.WriteLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>"); - writer.WriteLine("<tt xmlns=\"http://www.w3.org/ns/ttml\" xmlns:tts=\"http://www.w3.org/2006/04/ttaf1#styling\" lang=\"no\">"); - - writer.WriteLine("<head>"); - writer.WriteLine("<styling>"); - writer.WriteLine("<style id=\"italic\" tts:fontStyle=\"italic\" />"); - writer.WriteLine("<style id=\"left\" tts:textAlign=\"left\" />"); - writer.WriteLine("<style id=\"center\" tts:textAlign=\"center\" />"); - writer.WriteLine("<style id=\"right\" tts:textAlign=\"right\" />"); - writer.WriteLine("</styling>"); - writer.WriteLine("</head>"); - - writer.WriteLine("<body>"); - writer.WriteLine("<div>"); - - foreach (var trackEvent in info.TrackEvents) - { - var text = trackEvent.Text; - - text = NewLineEscapeRegex().Replace(text, "<br/>"); - - writer.WriteLine( - "<p begin=\"{0}\" dur=\"{1}\">{2}</p>", - trackEvent.StartPositionTicks, - trackEvent.EndPositionTicks - trackEvent.StartPositionTicks, - text); - } - - writer.WriteLine("</div>"); - writer.WriteLine("</body>"); - - writer.WriteLine("</tt>"); - } - } - } -} diff --git a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs deleted file mode 100644 index 3e0f47b5ae..0000000000 --- a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using MediaBrowser.Model.MediaInfo; - -namespace MediaBrowser.MediaEncoding.Subtitles -{ - /// <summary> - /// Subtitle writer for the WebVTT format. - /// </summary> - public partial class VttWriter : ISubtitleWriter - { - [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] - private static partial Regex NewlineEscapeRegex(); - - /// <inheritdoc /> - public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) - { - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - writer.WriteLine("WEBVTT"); - writer.WriteLine(); - writer.WriteLine("Region: id:subtitle width:80% lines:3 regionanchor:50%,100% viewportanchor:50%,90%"); - writer.WriteLine(); - foreach (var trackEvent in info.TrackEvents) - { - cancellationToken.ThrowIfCancellationRequested(); - - var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks); - var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks); - - // make sure the start and end times are different and sequential - if (endTime.TotalMilliseconds <= startTime.TotalMilliseconds) - { - endTime = startTime.Add(TimeSpan.FromMilliseconds(1)); - } - - writer.WriteLine(@"{0:hh\:mm\:ss\.fff} --> {1:hh\:mm\:ss\.fff} region:subtitle line:90%", startTime, endTime); - - var text = trackEvent.Text; - - // TODO: Not sure how to handle these - text = NewlineEscapeRegex().Replace(text, " "); - - writer.WriteLine(text); - writer.WriteLine(); - } - } - } - } -} diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index defd855ec0..78bb881ec2 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable // Must consume both stdout and stderr or deadlocks may occur // RedirectStandardOutput = true, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true, RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 98fc2e632f..4d052d8012 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -43,6 +43,7 @@ public class EncodingOptions VppTonemappingContrast = 1; H264Crf = 23; H265Crf = 28; + EncoderPreset = EncoderPreset.auto; DeinterlaceDoubleRate = false; DeinterlaceMethod = DeinterlaceMethod.yadif; EnableDecodingColorDepth10Hevc = true; @@ -61,7 +62,7 @@ public class EncodingOptions SubtitleExtractionTimeoutMinutes = 30; AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"]; HardwareDecodingCodecs = ["h264", "vc1"]; - HlsAudioSeekStrategy = HlsAudioSeekStrategy.DisableAccurateSeek; + HlsAudioSeekStrategy = HlsAudioSeekStrategy.TrimCopiedAudio; } /// <summary> @@ -217,7 +218,7 @@ public class EncodingOptions /// <summary> /// Gets or sets the encoder preset. /// </summary> - public EncoderPreset? EncoderPreset { get; set; } + public EncoderPreset EncoderPreset { get; set; } /// <summary> /// Gets or sets a value indicating whether the framerate is doubled when deinterlacing. @@ -307,6 +308,6 @@ public class EncodingOptions /// <summary> /// Gets or sets the method used for audio seeking in HLS. /// </summary> - [DefaultValue(HlsAudioSeekStrategy.DisableAccurateSeek)] + [DefaultValue(HlsAudioSeekStrategy.TrimCopiedAudio)] public HlsAudioSeekStrategy HlsAudioSeekStrategy { get; set; } } diff --git a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs index 49feeb435f..c9155faeb1 100644 --- a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs +++ b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs @@ -7,11 +7,12 @@ namespace MediaBrowser.Model.Configuration public enum HlsAudioSeekStrategy { /// <summary> - /// If the video stream is transcoded and the audio stream is copied, - /// seek the video stream to the same keyframe as the audio stream. The - /// resulting timestamps in the output streams may be inaccurate. + /// When video is transcoded and audio is copied, use a bitstream filter + /// to drop copied audio packets before the seek point, aligning them + /// with the accurately-seeked video. Timestamps are accurate and audio + /// remains stream-copied (no re-encoding overhead). /// </summary> - DisableAccurateSeek = 0, + TrimCopiedAudio = 0, /// <summary> /// Prevent audio streams from being copied if the video stream is transcoded. diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index 476060ceef..dd9a599a29 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -17,6 +17,7 @@ namespace MediaBrowser.Model.Configuration LyricFetcher, MediaSegmentProvider, LocalSimilarityProvider, - SimilarityProvider + SimilarityProvider, + SearchProvider } } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index a58c01c960..ac5c12304e 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -287,5 +287,5 @@ public class ServerConfiguration : BaseApplicationConfiguration /// <summary> /// Gets or sets a value indicating whether old authorization methods are allowed. /// </summary> - public bool EnableLegacyAuthorization { get; set; } = true; + public bool EnableLegacyAuthorization { get; set; } } diff --git a/MediaBrowser.Model/Dlna/CodecType.cs b/MediaBrowser.Model/Dlna/CodecType.cs index c9f090e4cc..12730a76fa 100644 --- a/MediaBrowser.Model/Dlna/CodecType.cs +++ b/MediaBrowser.Model/Dlna/CodecType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// <summary> + /// The codec type of a codec profile. + /// </summary> public enum CodecType { + /// <summary> + /// The profile applies to a video codec. + /// </summary> Video = 0, + + /// <summary> + /// The profile applies to the audio codec of a video stream. + /// </summary> VideoAudio = 1, + + /// <summary> + /// The profile applies to an audio codec. + /// </summary> Audio = 2 } } diff --git a/MediaBrowser.Model/Dlna/EncodingContext.cs b/MediaBrowser.Model/Dlna/EncodingContext.cs index 79ca6366d7..1408333d2e 100644 --- a/MediaBrowser.Model/Dlna/EncodingContext.cs +++ b/MediaBrowser.Model/Dlna/EncodingContext.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// <summary> + /// The encoding context. + /// </summary> public enum EncodingContext { + /// <summary> + /// The media is transcoded on the fly and delivered as a stream. + /// </summary> Streaming = 0, + + /// <summary> + /// The media is transcoded to a static file. + /// </summary> Static = 1 } } diff --git a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs index 300fab5c50..a28f422a2b 100644 --- a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs +++ b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// <summary> + /// The playback error code. + /// </summary> public enum PlaybackErrorCode { + /// <summary> + /// Playback of the item is not allowed. + /// </summary> NotAllowed = 0, + + /// <summary> + /// No stream compatible with the device profile was found. + /// </summary> NoCompatibleStream = 1, + + /// <summary> + /// The rate limit has been exceeded. + /// </summary> RateLimitExceeded = 2 } } diff --git a/MediaBrowser.Model/Dlna/ResolutionOptions.cs b/MediaBrowser.Model/Dlna/ResolutionOptions.cs index 774592abc7..b161b4a1e4 100644 --- a/MediaBrowser.Model/Dlna/ResolutionOptions.cs +++ b/MediaBrowser.Model/Dlna/ResolutionOptions.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// <summary> + /// The resolution constraints. + /// </summary> public class ResolutionOptions { + /// <summary> + /// Gets or sets the maximum width. + /// </summary> public int? MaxWidth { get; set; } + /// <summary> + /// Gets or sets the maximum height. + /// </summary> public int? MaxHeight { get; set; } } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 44697837ca..59f97d8c7c 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -575,7 +575,12 @@ namespace MediaBrowser.Model.Dlna { foreach (var profile in subtitleProfiles) { - if (profile.Method == SubtitleDeliveryMethod.External && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)) + 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))))) { return stream.Index; } @@ -946,6 +951,10 @@ namespace MediaBrowser.Model.Dlna } playlistItem.VideoCodecs = videoCodecs; + if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec)) + { + playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported; + } // Copy video codec options as a starting point, this applies to transcode and direct-stream playlistItem.MaxFramerate = videoStream?.ReferenceFrameRate; @@ -994,6 +1003,10 @@ namespace MediaBrowser.Model.Dlna var directAudioFailures = audioStreamWithSupportedCodec is null ? default : GetCompatibilityAudioCodec(options, item, container ?? string.Empty, audioStreamWithSupportedCodec, null, true, false); playlistItem.TranscodeReasons |= directAudioFailures; + if (audioStream is not null && audioStreamWithSupportedCodec is null) + { + playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported; + } var directAudioStreamSatisfied = audioStreamWithSupportedCodec is not null && !channelsExceedsLimit && directAudioFailures == 0; @@ -1451,7 +1464,7 @@ namespace MediaBrowser.Model.Dlna string? outputContainer, MediaStreamProtocol? transcodingSubProtocol) { - if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || transcodingSubProtocol != MediaStreamProtocol.hls)) + if (CanConsiderEmbedSubtitle(subtitleStream, playMethod, transcodingSubProtocol, outputContainer)) { // Look for supported embedded subs of the same format foreach (var profile in subtitleProfiles) @@ -1540,6 +1553,19 @@ namespace MediaBrowser.Model.Dlna return false; } + private static bool CanConsiderEmbedSubtitle(MediaStream subtitleStream, PlayMethod playMethod, MediaStreamProtocol? transcodingSubProtocol, string? outputContainer) + { + if (subtitleStream.IsExternal) + { + return playMethod == PlayMethod.Transcode + && transcodingSubProtocol != MediaStreamProtocol.hls + && IsSubtitleEmbedSupported(outputContainer); + } + + return playMethod != PlayMethod.Transcode + || transcodingSubProtocol != MediaStreamProtocol.hls; + } + private static SubtitleProfile? GetExternalSubtitleProfile(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, ITranscoderSupport transcoderSupport, bool allowConversion) { foreach (var profile in subtitleProfiles) @@ -1564,10 +1590,17 @@ namespace MediaBrowser.Model.Dlna continue; } - if ((profile.Method == SubtitleDeliveryMethod.External && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)) || + // 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)); + + if ((profile.Method == SubtitleDeliveryMethod.External + && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) || (profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream)) { - bool requiresConversion = !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase); + bool requiresConversion = !isVobSubMksProfile + && !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase); if (!requiresConversion) { diff --git a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs index cc0c6069bf..1563ffd17a 100644 --- a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs +++ b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dlna { + /// <summary> + /// The transcode seek info. + /// </summary> public enum TranscodeSeekInfo { + /// <summary> + /// The seek method is chosen automatically. + /// </summary> Auto = 0, + + /// <summary> + /// Seeking is performed by byte position. + /// </summary> Bytes = 1 } } diff --git a/MediaBrowser.Model/Dto/SessionInfoDto.cs b/MediaBrowser.Model/Dto/SessionInfoDto.cs index d727cd8741..16b201de9d 100644 --- a/MediaBrowser.Model/Dto/SessionInfoDto.cs +++ b/MediaBrowser.Model/Dto/SessionInfoDto.cs @@ -149,13 +149,7 @@ public class SessionInfoDto public IReadOnlyList<QueueItem>? NowPlayingQueue { get; set; } /// <summary> - /// Gets or sets the now playing queue full items. - /// </summary> - /// <value>The now playing queue full items.</value> - public IReadOnlyList<BaseItemDto>? NowPlayingQueueFullItems { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether the session has a custom device name. + /// Gets or sets a value indicating whether this session has a custom device name. /// </summary> /// <value><c>true</c> if this session has a custom device name; otherwise, <c>false</c>.</value> public bool HasCustomDeviceName { get; set; } diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index dad4a6e149..f057714bea 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -644,13 +644,32 @@ namespace MediaBrowser.Model.Entities } } + [JsonIgnore] + public bool IsVobSubSubtitleStream + { + get + { + if (Type != MediaStreamType.Subtitle) + { + return false; + } + + if (string.IsNullOrEmpty(Codec) && !IsExternal) + { + return false; + } + + return IsVobSubFormat(Codec); + } + } + /// <summary> /// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg. /// All text-based and pgs subtitles can be extracted. /// </summary> /// <value><c>true</c> if this is a extractable subtitle steam otherwise, <c>false</c>.</value> [JsonIgnore] - public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream; + public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream || IsVobSubSubtitleStream; /// <summary> /// Gets or sets a value indicating whether [supports external stream]. @@ -728,6 +747,7 @@ namespace MediaBrowser.Model.Entities return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase) || (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) && !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase) + && !codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase) && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)); @@ -741,6 +761,14 @@ namespace MediaBrowser.Model.Entities || string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase); } + public static bool IsVobSubFormat(string format) + { + string codec = format ?? string.Empty; + + return codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase) + || codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase); + } + public bool SupportsSubtitleConversionTo(string toCodec) { if (!IsTextSubtitleStream) diff --git a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs index 7c9ee18ca4..28c3c66af7 100644 --- a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs +++ b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs @@ -50,7 +50,7 @@ namespace MediaBrowser.Model.Extensions return 0; }) - .ThenByDescending(i => Math.Round(i.CommunityRating ?? 0, 1) ) + .ThenByDescending(i => Math.Round(i.CommunityRating ?? 0, 1)) .ThenByDescending(i => i.VoteCount ?? 0); } } diff --git a/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs index 53d0173750..9a21461d82 100644 --- a/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs +++ b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.MediaSegments; diff --git a/MediaBrowser.Model/Session/MessageCommand.cs b/MediaBrowser.Model/Session/MessageCommand.cs index cc9db8e6c5..e041a9cccd 100644 --- a/MediaBrowser.Model/Session/MessageCommand.cs +++ b/MediaBrowser.Model/Session/MessageCommand.cs @@ -1,17 +1,28 @@ #nullable disable -#pragma warning disable CS1591 using System.ComponentModel.DataAnnotations; namespace MediaBrowser.Model.Session { + /// <summary> + /// A command to display a message on a client. + /// </summary> public class MessageCommand { + /// <summary> + /// Gets or sets the message header. + /// </summary> public string Header { get; set; } + /// <summary> + /// Gets or sets the message text. + /// </summary> [Required(AllowEmptyStrings = false)] public string Text { get; set; } + /// <summary> + /// Gets or sets the timeout in milliseconds after which the message should be dismissed. + /// </summary> public long? TimeoutMs { get; set; } } } diff --git a/MediaBrowser.Model/Session/PlayMethod.cs b/MediaBrowser.Model/Session/PlayMethod.cs index 8067627843..2bd11cc91a 100644 --- a/MediaBrowser.Model/Session/PlayMethod.cs +++ b/MediaBrowser.Model/Session/PlayMethod.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Session { + /// <summary> + /// The play method. + /// </summary> public enum PlayMethod { + /// <summary> + /// The media is transcoded before it is sent to the client. + /// </summary> Transcode = 0, + + /// <summary> + /// The media is remuxed into a compatible container but the streams are not re-encoded. + /// </summary> DirectStream = 1, + + /// <summary> + /// The media is sent to the client as-is. + /// </summary> DirectPlay = 2 } } diff --git a/MediaBrowser.Model/Session/PlaystateRequest.cs b/MediaBrowser.Model/Session/PlaystateRequest.cs index ba2c024b76..040affa144 100644 --- a/MediaBrowser.Model/Session/PlaystateRequest.cs +++ b/MediaBrowser.Model/Session/PlaystateRequest.cs @@ -1,11 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Session { + /// <summary> + /// A request to change the playstate of a session. + /// </summary> public class PlaystateRequest { + /// <summary> + /// Gets or sets the playstate command. + /// </summary> public PlaystateCommand Command { get; set; } + /// <summary> + /// Gets or sets the seek position in ticks. + /// </summary> public long? SeekPositionTicks { get; set; } /// <summary> diff --git a/MediaBrowser.Model/Session/QueueItem.cs b/MediaBrowser.Model/Session/QueueItem.cs index 43920a8464..b9f3181da0 100644 --- a/MediaBrowser.Model/Session/QueueItem.cs +++ b/MediaBrowser.Model/Session/QueueItem.cs @@ -1,13 +1,21 @@ #nullable disable -#pragma warning disable CS1591 using System; namespace MediaBrowser.Model.Session; +/// <summary> +/// An item in a play queue. +/// </summary> public record QueueItem { + /// <summary> + /// Gets or sets the item id. + /// </summary> public Guid Id { get; set; } + /// <summary> + /// Gets or sets the playlist item id. + /// </summary> public string PlaylistItemId { get; set; } } diff --git a/MediaBrowser.Model/Session/RepeatMode.cs b/MediaBrowser.Model/Session/RepeatMode.cs index c6e173d6b8..c6c657d220 100644 --- a/MediaBrowser.Model/Session/RepeatMode.cs +++ b/MediaBrowser.Model/Session/RepeatMode.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Session { + /// <summary> + /// The repeat mode of a play queue. + /// </summary> public enum RepeatMode { + /// <summary> + /// Nothing is repeated. + /// </summary> RepeatNone = 0, + + /// <summary> + /// The whole queue is repeated. + /// </summary> RepeatAll = 1, + + /// <summary> + /// The current item is repeated. + /// </summary> RepeatOne = 2 } } diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs index 4429623dd9..ded66652ce 100644 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs +++ b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs @@ -50,7 +50,7 @@ namespace MediaBrowser.Model.SyncPlay /// </summary> Seek = 8, - /// <summary> + /// <summary> /// A user is signaling that playback is buffering. /// </summary> Buffer = 9, diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayGroupDoesNotExistUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayGroupDoesNotExistUpdate.cs index 7e2d10c8b8..ccf5fdb07e 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayGroupDoesNotExistUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayGroupDoesNotExistUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayGroupJoinedUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayGroupJoinedUpdate.cs index bfb49152a3..dcb039ee93 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayGroupJoinedUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayGroupJoinedUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayGroupLeftUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayGroupLeftUpdate.cs index 5ff60c5c27..f20e143e02 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayGroupLeftUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayGroupLeftUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayLibraryAccessDeniedUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayLibraryAccessDeniedUpdate.cs index 0d9a722f78..89e5706d86 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayLibraryAccessDeniedUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayLibraryAccessDeniedUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayNotInGroupUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayNotInGroupUpdate.cs index a3b610f619..4ba893be5b 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayNotInGroupUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayNotInGroupUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayPlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayPlayQueueUpdate.cs index 83d9bd40bc..a39f20735b 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayPlayQueueUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayPlayQueueUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayStateUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayStateUpdate.cs index 744ca46a0b..61cb8adbaa 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayStateUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayStateUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayUserJoinedUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayUserJoinedUpdate.cs index e8c6b4df41..247e6a57b2 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayUserJoinedUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayUserJoinedUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayUserLeftUpdate.cs b/MediaBrowser.Model/SyncPlay/SyncPlayUserLeftUpdate.cs index 97be8e63a8..ba053747cc 100644 --- a/MediaBrowser.Model/SyncPlay/SyncPlayUserLeftUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayUserLeftUpdate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace MediaBrowser.Model.SyncPlay; diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 2c393ca862..95e4d46c59 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -187,7 +187,7 @@ namespace MediaBrowser.Model.Users [Required(AllowEmptyStrings = false)] public string AuthenticationProviderId { get; set; } - [Required(AllowEmptyStrings= false)] + [Required(AllowEmptyStrings = false)] public string PasswordResetProviderId { get; set; } /// <summary> diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs new file mode 100644 index 0000000000..787d2ad878 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -0,0 +1,238 @@ +using System; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Books.ComicBookInfo.Models; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicBookInfo; + +/// <summary> +/// ComicBookInfo provider. +/// </summary> +public class ComicBookInfoProvider : IComicProvider +{ + private readonly ILogger<ComicBookInfoProvider> _logger; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="ComicBookInfoProvider"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ComicBookInfoProvider}"/> interface.</param> + public ComicBookInfoProvider(IFileSystem fileSystem, ILogger<ComicBookInfoProvider> logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var path = GetComicBookFile(info.Path)?.FullName; + + if (path is null) + { + _logger.LogError("could not load comic: {Path}", info.Path); + return new MetadataResult<Book> { HasMetadata = false }; + } + + try + { + Stream stream = AsyncFile.OpenRead(path); + await using (stream.ConfigureAwait(false)) + { + var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false); + await using (archive.ConfigureAwait(false)) + { + if (archive.Comment is null) + { + _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path); + return new MetadataResult<Book> { HasMetadata = false }; + } + + var comicBookMetadata = JsonSerializer.Deserialize<ComicBookInfoFormat>(archive.Comment, JsonDefaults.Options); + if (comicBookMetadata is null) + { + _logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path); + return new MetadataResult<Book> { HasMetadata = false }; + } + + return SaveMetadata(comicBookMetadata); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path); + return new MetadataResult<Book> { HasMetadata = false }; + } + } + + /// <inheritdoc /> + public bool HasItemChanged(BaseItem item) + { + var file = GetComicBookFile(item.Path); + + if (file is null) + { + return false; + } + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private MetadataResult<Book> SaveMetadata(ComicBookInfoFormat comic) + { + if (comic.Metadata is null) + { + return new MetadataResult<Book> { HasMetadata = false }; + } + + var book = ReadComicBookMetadata(comic.Metadata); + + if (book is null) + { + return new MetadataResult<Book> { HasMetadata = false }; + } + + var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true }; + + if (comic.Metadata.Language is not null) + { + metadataResult.ResultLanguage = ReadCultureInfoInto(comic.Metadata.Language); + } + + if (comic.Metadata.Credits.Count > 0) + { + ReadPeopleMetadata(comic.Metadata, metadataResult); + } + + return metadataResult; + } + + private FileSystemMetadata? GetComicBookFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + // only parse files that are known to have ComicBookInfo metadata + return fileInfo.Extension.Equals(".cbz", StringComparison.OrdinalIgnoreCase) ? fileInfo : null; + } + + private static Book? ReadComicBookMetadata(ComicBookInfoMetadata comic) + { + var book = new Book(); + var hasFoundMetadata = false; + + hasFoundMetadata |= ReadStringInto(comic.Title, title => book.Name = title); + hasFoundMetadata |= ReadStringInto(comic.Series, series => book.SeriesName = series); + hasFoundMetadata |= ReadStringInto(comic.Genre, genre => book.AddGenre(genre)); + hasFoundMetadata |= ReadStringInto(comic.Comments, overview => book.Overview = overview); + hasFoundMetadata |= ReadStringInto(comic.Publisher, publisher => book.SetStudios([publisher])); + + if (comic.PublicationYear is not null) + { + book.ProductionYear = comic.PublicationYear; + hasFoundMetadata = true; + } + + if (comic.Issue is not null) + { + book.IndexNumber = comic.Issue; + hasFoundMetadata = true; + } + + if (comic.Tags.Count > 0) + { + book.Tags = comic.Tags.ToArray(); + hasFoundMetadata = true; + } + + if (comic.PublicationYear is not null && comic.PublicationMonth is not null) + { + book.PremiereDate = ReadTwoPartDateInto(comic.PublicationYear.Value, comic.PublicationMonth.Value); + hasFoundMetadata = true; + } + + return hasFoundMetadata ? book : null; + } + + private static void ReadPeopleMetadata(ComicBookInfoMetadata comic, MetadataResult<Book> metadataResult) + { + foreach (var person in comic.Credits) + { + if (person.Person is null || person.Role is null) + { + continue; + } + + if (person.Person.Contains(',', StringComparison.InvariantCultureIgnoreCase)) + { + var name = person.Person.Split(','); + person.Person = name[1].Trim(' ') + " " + name[0].Trim(' '); + } + + if (!Enum.TryParse(person.Role, out PersonKind personKind)) + { + personKind = PersonKind.Unknown; + } + + if (string.Equals("Colorer", person.Role, StringComparison.OrdinalIgnoreCase)) + { + personKind = PersonKind.Colorist; + } + + metadataResult.AddPerson(new PersonInfo { Name = person.Person, Type = personKind }); + } + } + + private static string? ReadCultureInfoInto(string language) + { + try + { + return CultureInfo.GetCultureInfo(language).DisplayName; + } + catch (CultureNotFoundException) + { + return null; + } + } + + private static bool ReadStringInto(string? data, Action<string> commitResult) + { + if (!string.IsNullOrWhiteSpace(data)) + { + commitResult(data); + return true; + } + + return false; + } + + private static DateTime? ReadTwoPartDateInto(int year, int month) + { + try + { + // use first day of the month because this format doesn't include a day + return new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Unspecified); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs new file mode 100644 index 0000000000..fe7aa40456 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// <summary> +/// ComicBookInfo credit. +/// </summary> +public class ComicBookInfoCredit +{ + /// <summary> + /// Gets or sets the person name. + /// </summary> + [JsonPropertyName("person")] + public string? Person { get; set; } + + /// <summary> + /// Gets or sets the role. + /// </summary> + [JsonPropertyName("role")] + public string? Role { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs new file mode 100644 index 0000000000..5c4e3d948f --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// <summary> +/// ComicBookInfo format. +/// </summary> +public class ComicBookInfoFormat +{ + /// <summary> + /// Gets or sets the app ID. + /// </summary> + [JsonPropertyName("appID")] + public string? AppId { get; set; } + + /// <summary> + /// Gets or sets the last modified timestamp. + /// </summary> + [JsonPropertyName("lastModified")] + public string? LastModified { get; set; } + + /// <summary> + /// Gets or sets the metadata. + /// </summary> + [JsonPropertyName("ComicBookInfo/1.0")] + public ComicBookInfoMetadata? Metadata { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs new file mode 100644 index 0000000000..42e1b3d4f6 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Books.ComicBookInfo.Models; + +/// <summary> +/// ComicBookInfo metadata. +/// </summary> +public class ComicBookInfoMetadata +{ + /// <summary> + /// Gets or sets the series. + /// </summary> + [JsonPropertyName("series")] + public string? Series { get; set; } + + /// <summary> + /// Gets or sets the title. + /// </summary> + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// <summary> + /// Gets or sets the publisher. + /// </summary> + [JsonPropertyName("publisher")] + public string? Publisher { get; set; } + + /// <summary> + /// Gets or sets the publication month. + /// </summary> + [JsonPropertyName("publicationMonth")] + public int? PublicationMonth { get; set; } + + /// <summary> + /// Gets or sets the publication year. + /// </summary> + [JsonPropertyName("publicationYear")] + public int? PublicationYear { get; set; } + + /// <summary> + /// Gets or sets the issue number. + /// </summary> + [JsonPropertyName("issue")] + public int? Issue { get; set; } + + /// <summary> + /// Gets or sets the number of issues. + /// </summary> + [JsonPropertyName("numberOfIssues")] + public int? NumberOfIssues { get; set; } + + /// <summary> + /// Gets or sets the volume number. + /// </summary> + [JsonPropertyName("volume")] + public int? Volume { get; set; } + + /// <summary> + /// Gets or sets the number of volumes. + /// </summary> + [JsonPropertyName("numberOfVolumes")] + public int? NumberOfVolumes { get; set; } + + /// <summary> + /// Gets or sets the rating. + /// </summary> + [JsonPropertyName("rating")] + public int? Rating { get; set; } + + /// <summary> + /// Gets or sets the genre. + /// </summary> + [JsonPropertyName("genre")] + public string? Genre { get; set; } + + /// <summary> + /// Gets or sets the language. + /// </summary> + [JsonPropertyName("language")] + public string? Language { get; set; } + + /// <summary> + /// Gets or sets the country. + /// </summary> + [JsonPropertyName("country")] + public string? Country { get; set; } + + /// <summary> + /// Gets or sets the list of credits. + /// </summary> + [JsonPropertyName("credits")] + public IReadOnlyList<ComicBookInfoCredit> Credits { get; set; } = Array.Empty<ComicBookInfoCredit>(); + + /// <summary> + /// Gets or sets the list of tags. + /// </summary> + [JsonPropertyName("tags")] + public IReadOnlyList<string> Tags { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the comments. + /// </summary> + [JsonPropertyName("comments")] + public string? Comments { get; set; } +} diff --git a/MediaBrowser.Providers/Books/ComicImageProvider.cs b/MediaBrowser.Providers/Books/ComicImageProvider.cs new file mode 100644 index 0000000000..34936cff13 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicImageProvider.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +namespace MediaBrowser.Providers.Books; + +/// <summary> +/// The ComicImageProvider tries to find either an image named "cover" or, in case that +/// fails, just takes the first image inside the archive, hoping that it is the cover. +/// </summary> +public class ComicImageProvider : IDynamicImageProvider +{ + private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"]; + private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]; + + private readonly ILogger<ComicImageProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ComicImageProvider"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ComicImageProvider}"/> interface.</param> + public ComicImageProvider(ILogger<ComicImageProvider> logger) + { + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Comic Book Archive Cover Extractor"; + + /// <inheritdoc /> + public async Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(item.Path); + + if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + return await LoadCoverAsync(item, cancellationToken).ConfigureAwait(false); + } + + return new DynamicImageResponse { HasImage = false }; + } + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + yield return ImageType.Primary; + } + + /// <inheritdoc /> + public bool Supports(BaseItem item) + { + return item is Book; + } + + /// <summary> + /// Tries to load a cover from the CBZ archive. Returns a response + /// with no image if nothing is found. + /// </summary> + /// <param name="item">Item to check for covers.</param> + /// <param name="cancellationToken">The cancellation token.</param> + private async Task<DynamicImageResponse> LoadCoverAsync(BaseItem item, CancellationToken cancellationToken) + { + var memoryStream = new MemoryStream(); + + try + { + ImageFormat imageFormat; + + using (Stream stream = AsyncFile.OpenRead(item.Path)) + { + var archive = await ArchiveFactory.OpenAsyncArchive(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + await using (archive.ConfigureAwait(false)) + { + // throw exception to log results if no cover is found + (var cover, imageFormat) = await FindCoverEntryInArchiveAsync(archive).ConfigureAwait(false) + ?? throw new InvalidOperationException("no supported cover found"); + + // copy the cover to memory stream + var coverStream = await cover.OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); + await using (coverStream.ConfigureAwait(false)) + { + await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + } + } + } + + // reset stream position after copying + memoryStream.Position = 0; + + return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat }; + } + catch (Exception e) + { + _logger.LogError(e, "failed to load cover from {Path}", item.Path); + return new DynamicImageResponse { HasImage = false }; + } + } + + /// <summary> + /// Tries to find the entry containing the cover. + /// </summary> + /// <param name="archive">The archive to search.</param> + /// <returns>The search result.</returns> + private async ValueTask<(IArchiveEntry CoverEntry, ImageFormat ImageFormat)?> FindCoverEntryInArchiveAsync(IAsyncArchive archive) + { + IArchiveEntry? cover; + + // only some comics will explicitly name their cover file + // in many cases the cover will simply be the first image in the archive + foreach (var extension in _coverExtensions) + { + cover = await archive.EntriesAsync.FirstOrDefaultAsync(e => e.Key == "cover" + extension).ConfigureAwait(false); + + if (cover is not null) + { + var imageFormat = GetImageFormat(extension); + + return (cover, imageFormat); + } + } + + cover = await archive.EntriesAsync.OrderBy(x => x.Key) + .FirstOrDefaultAsync(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase)) + .ConfigureAwait(false); + + if (cover is not null) + { + var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty)); + + return (cover, imageFormat); + } + + return null; + } + + private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch + { + ".jpg" => ImageFormat.Jpg, + ".jpeg" => ImageFormat.Jpg, + ".png" => ImageFormat.Png, + ".webp" => ImageFormat.Webp, + ".bmp" => ImageFormat.Bmp, + ".gif" => ImageFormat.Gif, + ".svg" => ImageFormat.Svg, + _ => throw new ArgumentException($"unsupported extension: {extension}"), + }; +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs new file mode 100644 index 0000000000..4e8dc405ec --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// <summary> +/// ComicInfo reader. +/// </summary> +public static class ComicInfoReader +{ + /// <summary> + /// Filename to check for comic metadata either next to the comic file or inside the archive. + /// </summary> + public const string ComicRackMetaFile = "ComicInfo.xml"; + + /// <summary> + /// Read comic book metadata. + /// </summary> + /// <param name="xml">The XDocument to read for comic metadata.</param> + /// <returns>The resulting book.</returns> + public static Book? ReadComicBookMetadata(XDocument xml) + { + var book = new Book(); + var hasFoundMetadata = false; + + // this value is only used internally since Jellyfin has no manga flag + var isManga = false; + + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Title", title => book.Name = title); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Manga", manga => isManga = manga.Equals("Yes", StringComparison.OrdinalIgnoreCase)); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Series", series => book.SeriesName = series); + hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Number", issue => book.IndexNumber = issue); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Summary", summary => book.Overview = summary); + hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Year", year => book.ProductionYear = year); + hasFoundMetadata |= ReadThreePartDateInto(xml, "ComicInfo/Year", "ComicInfo/Month", "ComicInfo/Day", dateTime => book.PremiereDate = dateTime); + hasFoundMetadata |= ReadCommaSeparatedStringsInto(xml, "ComicInfo/Genre", genres => + { + foreach (var genre in genres) + { + book.AddGenre(genre); + } + }); + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Publisher", publisher => book.SetStudios([publisher])); + + hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/AlternateSeries", title => + { + if (isManga) + { + // Software like ComicTagger (https://github.com/comictagger/comictagger) will use + // this field for the series name in the original language when tagging manga. + book.OriginalTitle = title; + } + else + { + // Some US comics can be part of cross-over story arcs. This field is then used to + // specify an alternate series. + } + }); + + return hasFoundMetadata ? book : null; + } + + /// <summary> + /// Read people metadata. + /// </summary> + /// <param name="xml">The XDocument to read for people metadata.</param> + /// <param name="metadataResult">The metadata result to update.</param> + public static void ReadPeopleMetadata(XDocument xml, MetadataResult<Book> metadataResult) + { + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors => + { + foreach (var p in authors) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Author }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Penciller", pencillers => + { + foreach (var p in pencillers) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Penciller }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Inker", inkers => + { + foreach (var p in inkers) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Inker }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Letterer", letterers => + { + foreach (var p in letterers) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Letterer }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/CoverArtist", artists => + { + foreach (var p in artists) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.CoverArtist }); + } + }); + + ReadCommaSeparatedStringsInto(xml, "ComicInfo/Colourist", colorists => + { + foreach (var p in colorists) + { + metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Colorist }); + } + }); + } + + /// <summary> + /// Read culture information. + /// </summary> + /// <param name="xml">the XDocument to read for metadata.</param> + /// <param name="xPath">The path to search.</param> + /// <param name="commitResult">The action to take after parsing all metadata.</param> + public static void ReadCultureInfoInto(XDocument xml, string xPath, Action<CultureInfo> commitResult) + { + string? culture = null; + + if (!ReadStringInto(xml, xPath, value => culture = value)) + { + return; + } + + // culture cannot be null here as the method would have returned earlier + commitResult(new CultureInfo(culture!)); + } + + private static bool ReadStringInto(XDocument xml, string xPath, Action<string> commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value)) + { + commitResult(resultElement.Value); + return true; + } + + return false; + } + + private static bool ReadCommaSeparatedStringsInto(XDocument xml, string xPath, Action<IEnumerable<string>> commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is null || string.IsNullOrWhiteSpace(resultElement.Value)) + { + return false; + } + + try + { + var splits = resultElement.Value.Split(",").Select(p => p.Trim()).ToArray(); + if (splits.Length < 1) + { + return false; + } + + commitResult(splits); + return true; + } + catch (ArgumentNullException) + { + return false; + } + } + + private static bool ReadIntInto(XDocument xml, string xPath, Action<int> commitResult) + { + var resultElement = xml.XPathSelectElement(xPath); + + if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value)) + { + return ParseInt(resultElement.Value, commitResult); + } + + return false; + } + + private static bool ReadThreePartDateInto(XDocument xml, string yearXPath, string monthXPath, string dayXPath, Action<DateTime> commitResult) + { + int year = 0; + int month = 0; + int day = 0; + var parsed = false; + + parsed |= ReadIntInto(xml, yearXPath, num => year = num); + parsed |= ReadIntInto(xml, monthXPath, num => month = num); + parsed |= ReadIntInto(xml, dayXPath, num => day = num); + + if (!parsed) + { + return false; + } + + try + { + var dateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified); + + commitResult(dateTime); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static bool ParseInt(string input, Action<int> commitResult) + { + if (int.TryParse(input, out var parsed)) + { + commitResult(parsed); + return true; + } + + return false; + } +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs new file mode 100644 index 0000000000..02cc02b7f3 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// <summary> +/// Handles metadata for comics which is saved as an XML document. This XML document is not part +/// of the comic itself but an external file. +/// </summary> +public class ExternalComicInfoProvider : IComicProvider +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger<ExternalComicInfoProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ExternalComicInfoProvider"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExternalComicInfoProvider}"/> interface.</param> + public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger<ExternalComicInfoProvider> logger) + { + _logger = logger; + _fileSystem = fileSystem; + } + + /// <inheritdoc /> + public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false); + + if (comicInfoXml is null) + { + _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path); + return new MetadataResult<Book> { HasMetadata = false }; + } + + var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml); + + if (book is null) + { + return new MetadataResult<Book> { HasMetadata = false }; + } + + var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true }; + + ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + + return metadataResult; + } + + /// <inheritdoc /> + public bool HasItemChanged(BaseItem item) + { + var file = GetXmlFilePath(item.Path); + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken) + { + var path = GetXmlFilePath(info.Path).FullName; + + if (path is null) + { + return null; + } + + try + { + using var reader = XmlReader.Create(path, 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); + return null; + } + } + + private FileSystemMetadata GetXmlFilePath(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!); + var file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".xml")); + + return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, ComicInfoReader.ComicRackMetaFile)); + } +} diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs new file mode 100644 index 0000000000..98a6aba7d6 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs @@ -0,0 +1,120 @@ +using System; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Books.ComicInfo; + +/// <summary> +/// Handles metadata for comics which is saved as an XML document inside the comic itself. +/// </summary> +public class InternalComicInfoProvider : IComicProvider +{ + private readonly IFileSystem _fileSystem; + private readonly ILogger<InternalComicInfoProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="InternalComicInfoProvider"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{InternalComicInfoProvider}"/> interface.</param> + public InternalComicInfoProvider(IFileSystem fileSystem, ILogger<InternalComicInfoProvider> logger) + { + _logger = logger; + _fileSystem = fileSystem; + } + + /// <inheritdoc /> + public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false); + + if (comicInfoXml is null) + { + _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path); + return new MetadataResult<Book> { HasMetadata = false }; + } + + var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml); + + if (book is null) + { + return new MetadataResult<Book> { HasMetadata = false }; + } + + var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true }; + + ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + + return metadataResult; + } + + /// <inheritdoc /> + public bool HasItemChanged(BaseItem item) + { + var file = GetComicBookFile(item.Path); + + if (file is null) + { + return false; + } + + return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + } + + private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken) + { + var path = GetComicBookFile(info.Path)?.FullName; + + if (path is null) + { + return null; + } + + try + { + // open the comic archive and try to get the ComicInfo.xml entry + using var comicBookFile = await ZipFile.OpenReadAsync(path, cancellationToken).ConfigureAwait(false); + var container = comicBookFile.GetEntry(ComicInfoReader.ComicRackMetaFile); + + if (container is null) + { + return null; + } + + using var containerStream = await container.OpenAsync(cancellationToken).ConfigureAwait(false); + var comicInfoXml = XDocument.LoadAsync(containerStream, LoadOptions.None, cancellationToken); + + return await comicInfoXml.ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogError(e, "could not load internal XML from {Path}", path); + return null; + } + } + + private FileSystemMetadata? GetComicBookFile(string path) + { + var fileInfo = _fileSystem.GetFileSystemInfo(path); + + if (fileInfo.IsDirectory) + { + return null; + } + + // only parse files that are known to have internal metadata + if (!string.Equals(fileInfo.Extension, ".cbz", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return fileInfo; + } +} diff --git a/MediaBrowser.Providers/Books/ComicProvider.cs b/MediaBrowser.Providers/Books/ComicProvider.cs new file mode 100644 index 0000000000..d59c58c330 --- /dev/null +++ b/MediaBrowser.Providers/Books/ComicProvider.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books; + +/// <summary> +/// Comic provider. +/// </summary> +public class ComicProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor +{ + private readonly IEnumerable<IComicProvider> _comicProviders; + + /// <summary> + /// Initializes a new instance of the <see cref="ComicProvider"/> class. + /// </summary> + /// <param name="comicProviders">The list of comic providers.</param> + public ComicProvider(IEnumerable<IComicProvider> comicProviders) + { + _comicProviders = comicProviders; + } + + /// <inheritdoc /> + public string Name => "Comic Provider"; + + /// <inheritdoc /> + public async Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken) + { + foreach (IComicProvider comicProvider in _comicProviders) + { + var metadata = await comicProvider.ReadMetadata(info, directoryService, cancellationToken).ConfigureAwait(false); + + if (metadata.HasMetadata) + { + return metadata; + } + } + + return new MetadataResult<Book> { HasMetadata = false }; + } + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + foreach (IComicProvider iComicFileProvider in _comicProviders) + { + var fileChanged = iComicFileProvider.HasItemChanged(item); + + if (fileChanged) + { + return fileChanged; + } + } + + return false; + } +} diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs b/MediaBrowser.Providers/Books/ComicVine/ComicVineExternalId.cs index 8cbd1f89a7..e2e785eaca 100644 --- a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalId.cs +++ b/MediaBrowser.Providers/Books/ComicVine/ComicVineExternalId.cs @@ -3,7 +3,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -namespace MediaBrowser.Providers.Plugins.ComicVine +namespace MediaBrowser.Providers.Books.ComicVine { /// <inheritdoc /> public class ComicVineExternalId : IExternalId diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs b/MediaBrowser.Providers/Books/ComicVine/ComicVineExternalUrlProvider.cs index 9122399179..a8450ec599 100644 --- a/MediaBrowser.Providers/Plugins/ComicVine/ComicVineExternalUrlProvider.cs +++ b/MediaBrowser.Providers/Books/ComicVine/ComicVineExternalUrlProvider.cs @@ -3,7 +3,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -namespace MediaBrowser.Providers.Plugins.ComicVine; +namespace MediaBrowser.Providers.Books.ComicVine; /// <inheritdoc/> public class ComicVineExternalUrlProvider : IExternalUrlProvider diff --git a/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs b/MediaBrowser.Providers/Books/ComicVine/ComicVinePersonExternalId.cs index 26b8e11380..f625fb9649 100644 --- a/MediaBrowser.Providers/Plugins/ComicVine/ComicVinePersonExternalId.cs +++ b/MediaBrowser.Providers/Books/ComicVine/ComicVinePersonExternalId.cs @@ -3,7 +3,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -namespace MediaBrowser.Providers.Plugins.ComicVine +namespace MediaBrowser.Providers.Books.ComicVine { /// <inheritdoc /> public class ComicVinePersonExternalId : IExternalId diff --git a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs b/MediaBrowser.Providers/Books/GoogleBooks/GoogleBooksExternalId.cs index 02d3b36974..aac8cdff65 100644 --- a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalId.cs +++ b/MediaBrowser.Providers/Books/GoogleBooks/GoogleBooksExternalId.cs @@ -3,7 +3,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -namespace MediaBrowser.Providers.Plugins.GoogleBooks +namespace MediaBrowser.Providers.Books.GoogleBooks { /// <inheritdoc /> public class GoogleBooksExternalId : IExternalId diff --git a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs b/MediaBrowser.Providers/Books/GoogleBooks/GoogleBooksExternalUrlProvider.cs index 95047ee83e..0559db2e2b 100644 --- a/MediaBrowser.Providers/Plugins/GoogleBooks/GoogleBooksExternalUrlProvider.cs +++ b/MediaBrowser.Providers/Books/GoogleBooks/GoogleBooksExternalUrlProvider.cs @@ -3,7 +3,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -namespace MediaBrowser.Providers.Plugins.GoogleBooks; +namespace MediaBrowser.Providers.Books.GoogleBooks; /// <inheritdoc/> public class GoogleBooksExternalUrlProvider : IExternalUrlProvider diff --git a/MediaBrowser.Providers/Books/IComicProvider.cs b/MediaBrowser.Providers/Books/IComicProvider.cs new file mode 100644 index 0000000000..06c8bd1136 --- /dev/null +++ b/MediaBrowser.Providers/Books/IComicProvider.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; + +namespace MediaBrowser.Providers.Books; + +/// <summary> +/// Comic provider interface. +/// </summary> +public interface IComicProvider +{ + /// <summary> + /// Read the item metadata. + /// </summary> + /// <param name="info">The item information.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The metadata result.</returns> + ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken); + + /// <summary> + /// Determine whether the item has changed. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>Item change status.</returns> + bool HasItemChanged(BaseItem item); +} diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs index 6e678802c1..d2331c6864 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Threading; using System.Threading.Tasks; using System.Xml; diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs index 15ea2ce5ab..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> @@ -125,7 +129,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat ReadStringInto("//dc:date", date => { - if (DateTime.TryParse(date, out var dateValue)) + if (DateTime.TryParse(date, CultureInfo.InvariantCulture, out var dateValue)) { book.PremiereDate = dateValue.Date; book.ProductionYear = dateValue.Date.Year; @@ -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/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index d9a8c044b9..aa4ca5afd8 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Manager { ArgumentException.ThrowIfNullOrEmpty(mimeType); - var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio; + var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && (item is AudioBook || item is not Audio); if (type != ImageType.Primary && item is Episode) { diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index c2e523cfaf..a438a94c40 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager return providers; } - protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options) + protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, MetadataRefreshOptions options) { // Get providers to refresh var providers = allImageProviders.Where(i => i is not ILocalImageProvider); + // When identifying, run the provider the user picked first so the correct image is used. + if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName)) + { + providers = providers + .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1); + } + var dateLastImageRefresh = item.DateLastRefreshed; // Run all if either of these flags are true @@ -831,8 +838,16 @@ namespace MediaBrowser.Providers.Manager var isLocalLocked = temp.Item.IsLocked; if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly)) { - var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken) - .ConfigureAwait(false); + var remoteProviders = providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(); + + // When identifying, run the provider the user picked first so the correct IDs are used. + if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName)) + { + remoteProviders = remoteProviders + .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1); + } + + var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false); refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; @@ -1108,7 +1123,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; } diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 8b727a8cac..2b0f480b1c 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -21,7 +21,9 @@ <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" /> </ItemGroup> @@ -47,22 +49,53 @@ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> + <!-- AudioDb --> <ItemGroup> <None Remove="Plugins\AudioDb\Configuration\config.html" /> <EmbeddedResource Include="Plugins\AudioDb\Configuration\config.html" /> - <None Remove="Plugins\Omdb\Configuration\config.html" /> - <EmbeddedResource Include="Plugins\Omdb\Configuration\config.html" /> + <None Remove="Plugins\AudioDb\jellyfin-plugin-tadb.svg" /> + <EmbeddedResource Include="Plugins\AudioDb\jellyfin-plugin-tadb.svg" /> + </ItemGroup> + + <!-- ListenBrainz --> + <ItemGroup> <None Remove="Plugins\ListenBrainz\Configuration\config.html" /> <EmbeddedResource Include="Plugins\ListenBrainz\Configuration\config.html" /> <None Remove="Plugins\ListenBrainz\Configuration\ListenBrainz_logo.svg" /> <EmbeddedResource Include="Plugins\ListenBrainz\Configuration\ListenBrainz_logo.svg" /> <None Remove="Plugins\ListenBrainz\Configuration\NOTICE.md" /> <EmbeddedResource Include="Plugins\ListenBrainz\Configuration\NOTICE.md" /> + </ItemGroup> + + <!-- MusicBrainz --> + <ItemGroup> <None Remove="Plugins\MusicBrainz\Configuration\config.html" /> <EmbeddedResource Include="Plugins\MusicBrainz\Configuration\config.html" /> + <None Remove="Plugins\MusicBrainz\jellyfin-plugin-musicbrainz.svg" /> + <EmbeddedResource Include="Plugins\MusicBrainz\jellyfin-plugin-musicbrainz.svg" /> + </ItemGroup> + + <!-- Omdb --> + <ItemGroup> + <None Remove="Plugins\Omdb\Configuration\config.html" /> + <EmbeddedResource Include="Plugins\Omdb\Configuration\config.html" /> + <None Remove="Plugins\Omdb\jellyfin-plugin-omdb.png" /> + <EmbeddedResource Include="Plugins\Omdb\jellyfin-plugin-omdb.png" /> + </ItemGroup> + + <!-- StudioImages --> + <ItemGroup> <None Remove="Plugins\StudioImages\Configuration\config.html" /> <EmbeddedResource Include="Plugins\StudioImages\Configuration\config.html" /> + <None Remove="Plugins\StudioImages\jellyfin-plugin-studioimages.svg" /> + <EmbeddedResource Include="Plugins\StudioImages\jellyfin-plugin-studioimages.svg" /> + </ItemGroup> + + <!-- Tmdb --> + <ItemGroup> <None Remove="Plugins\Tmdb\Configuration\config.html" /> <EmbeddedResource Include="Plugins\Tmdb\Configuration\config.html" /> + <None Remove="Plugins\Tmdb\jellyfin-plugin-tmdb.svg" /> + <EmbeddedResource Include="Plugins\Tmdb\jellyfin-plugin-tmdb.svg" /> </ItemGroup> </Project> diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 0ecbb6f068..b70cba5b3b 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -549,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null); var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics; if (!string.IsNullOrWhiteSpace(lyrics) - && tryExtractEmbeddedLyrics) + && (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata)) { await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 789df8f061..221c6bff5e 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, @@ -214,6 +217,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/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs index f1582febf2..1d3a273354 100644 --- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs +++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs @@ -102,7 +102,8 @@ namespace MediaBrowser.Providers.MediaInfo DtoOptions = new DtoOptions(true), SourceTypes = new[] { SourceType.Library }, Parent = library, - Recursive = true + Recursive = true, + IncludeOwnedItems = true }; if (skipIfAudioTrackMatches) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index 49ece22a98..0acd44afbe 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -142,7 +142,9 @@ namespace MediaBrowser.Providers.Plugins.AudioDb if (string.IsNullOrWhiteSpace(overview)) { - overview = result.strDescriptionEN; + overview = string.IsNullOrWhiteSpace(result.strDescriptionEN) + ? result.strDescription + : result.strDescriptionEN; } item.Overview = (overview ?? string.Empty).StripHtml(); @@ -240,6 +242,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public string strAlbumCDart { get; set; } + public string strDescription { get; set; } + public string strDescriptionEN { get; set; } public string strDescriptionDE { get; set; } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs index 6c2ad0573e..bb6b67286d 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/Plugin.cs @@ -5,12 +5,13 @@ using System; using System.Collections.Generic; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.AudioDb { - public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage { public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) @@ -29,6 +30,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb // TODO remove when plugin removed from server. public override string ConfigurationFileName => "Jellyfin.Plugin.AudioDb.xml"; + public string ImageResourceName => GetType().Namespace + ".jellyfin-plugin-tadb.svg"; + public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/AudioDb/jellyfin-plugin-tadb.svg b/MediaBrowser.Providers/Plugins/AudioDb/jellyfin-plugin-tadb.svg new file mode 100644 index 0000000000..94fa55cc9c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/AudioDb/jellyfin-plugin-tadb.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1920 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect x="-0" y="-0" width="1920" height="1080" style="fill:#15191f;"/><path d="M537.166,347.444c8.07,-10.159 20.665,-17.047 33.734,-17.407c7.234,-0.375 14.064,3.372 19.136,8.286c7.925,7.81 8.69,21.499 1.816,30.217c-1.671,0.994 -2.666,2.406 -2.983,4.265c-6.023,7.061 -15.808,9.51 -24.741,7.954c0.129,-0.807 0.404,-2.406 0.547,-3.213c-0.288,0.692 -0.865,2.075 -1.153,2.767c-5.908,-2.19 -11.629,-5.576 -14.438,-11.47c-0.534,-0.663 -1.586,-1.989 -2.118,-2.651c1.513,-2.262 3.156,-4.438 4.813,-6.6c1.08,5.908 3.674,11.917 9.12,15.015c5.261,3.703 11.888,2.968 17.911,2.306c7.033,-4.856 13.603,-11.701 13.906,-20.765c1.672,-12.825 -11.052,-23.632 -23.329,-22.436c-12.551,0.418 -22.855,8.487 -31.384,16.931c-0.216,-0.793 -0.635,-2.406 -0.837,-3.199Z" style="fill-rule:nonzero;"/><path d="M594.388,339.719c13.79,10.995 11.341,35.506 -4.222,43.662c-0.259,-0.144 -0.777,-0.447 -1.023,-0.591c0.347,-2.277 0.101,-4.467 -0.734,-6.6c0.23,-0.231 0.677,-0.677 0.893,-0.893c2.581,-0.965 5.246,-2.147 5.188,-5.389c1.413,-5.029 3.891,-9.871 3.761,-15.217c0.288,-5.288 -2.089,-10.145 -3.862,-14.972Z" style="fill-rule:nonzero;"/><path d="M533.896,352.054c0.533,0.13 1.585,0.403 2.119,0.548c0.432,1.614 -0.692,2.997 -1.903,3.905c-0.62,-0.605 -1.844,-1.816 -2.45,-2.406c0.562,-0.519 1.671,-1.542 2.234,-2.046Z" style="fill-rule:nonzero;"/><path d="M527.399,362.127c0.867,0.145 2.602,0.448 3.469,0.593c-0.347,0.694 -1.055,2.082 -1.402,2.775c-0.636,-0.593 -1.908,-1.778 -2.559,-2.371l0.492,-0.997Z" style="fill-rule:nonzero;"/><path d="M541.691,374.548c1.325,-1.902 2.652,-3.804 3.919,-5.75c0.591,0.418 1.758,1.254 2.349,1.672c-1.096,2.161 -2.104,4.366 -3.113,6.556c-0.792,-0.62 -2.377,-1.859 -3.156,-2.478Z" style="fill-rule:nonzero;"/><path d="M548.693,415.501c-6.671,-13.113 -8.098,-29.9 0.375,-42.552c1.887,1.816 3.818,3.617 5.75,5.404c-7.493,11.038 -7.306,26.183 -0.345,37.437c-1.845,0.548 -5.232,3.285 -5.779,-0.288Z" style="fill-rule:nonzero;"/><path d="M555.382,379.275c8.617,3.343 18.603,5.706 27.191,0.908c1.283,1.888 2.911,3.458 5.346,3.43c-0.259,0.533 -0.778,1.614 -1.037,2.147c-10.591,4.237 -23.819,2.406 -31.5,-6.484Z" style="fill-rule:nonzero;"/><path d="M545.324,432.764c0.765,0.737 0.765,0.737 0,0Z" style="fill-rule:nonzero;"/><path d="M598.524,436.814c0.246,-0.159 0.737,-0.477 0.997,-0.636l-0.449,0.304l-0.549,0.332Z" style="fill-rule:nonzero;"/><path d="M603.119,441.538c0.781,0.853 0.781,0.853 0,0Z" style="fill-rule:nonzero;"/><path d="M647.1,441.308c1.557,1.038 2.291,3.185 1.584,4.957c-1.441,1.254 -2.939,2.464 -4.351,3.761c0.619,-2.997 0.966,-6.153 2.767,-8.718Z" style="fill-rule:nonzero;"/><path d="M612.63,449.046l0.693,0.029l-0.015,0.723l-0.723,-0.029l0.044,-0.723Z" style="fill-rule:nonzero;"/><path d="M640.572,472.908c2.897,0.606 -0.331,6.758 -2.708,5.173c-0.894,-2.003 0.951,-4.338 2.708,-5.173Z" style="fill-rule:nonzero;"/><path d="M448.849,474.551c0.708,0.765 0.708,0.765 0,0Z" style="fill-rule:nonzero;"/><path d="M451.63,477.55c0.766,0.723 0.766,0.723 0,0Z" style="fill-rule:nonzero;"/><path d="M443.057,503.803c0.766,0.809 0.766,0.809 0,0Z" style="fill-rule:nonzero;"/><path d="M457.653,506.586l0.246,0.043l0.101,0.101c-0.087,-0.043 -0.26,-0.116 -0.347,-0.143Z" style="fill-rule:nonzero;"/><path d="M454.569,509.249c0.781,0.708 0.781,0.708 0,0Z" style="fill-rule:nonzero;"/><path d="M460.377,509.077c0.781,0.723 0.781,0.723 0,0Z" style="fill-rule:nonzero;"/><path d="M457.481,511.945c0.737,0.766 0.737,0.766 0,0Z" style="fill-rule:nonzero;"/><path d="M443.201,520.72l0.015,-0.015l0.247,0.043c-0.073,-0.015 -0.189,-0.028 -0.262,-0.028Z" style="fill-rule:nonzero;"/><path d="M420.001,526.948l0.188,-0.145c-0.029,0.086 -0.087,0.246 -0.13,0.317l-0.058,-0.173Z" style="fill-rule:nonzero;"/><path d="M425.477,526.644c0.231,0.058 0.708,0.174 0.94,0.216l-0.679,0.333l-0.26,-0.55Z" style="fill-rule:nonzero;"/><path d="M417.061,529.48c0.781,0.796 0.781,0.796 0,0Z" style="fill-rule:nonzero;"/><path d="M636.898,539.382c0.59,-0.563 1.786,-1.687 2.392,-2.249c-0.938,2.752 0.316,5.217 1.642,7.565c-1.44,-0.663 -2.752,-1.527 -3.717,-2.767c-0.086,-0.634 -0.244,-1.917 -0.316,-2.55Z" style="fill-rule:nonzero;"/><path d="M634.347,540.173c0.274,-0.043 0.821,-0.143 1.096,-0.187c0.244,0.275 0.734,0.808 0.979,1.081c-0.114,0.707 -0.331,2.118 -0.446,2.84c-0.707,0.993 -2.118,3.011 -2.824,4.006c-0.145,3.17 4.048,1.7 5.965,2.594c-0.49,0.576 -1.484,1.729 -1.974,2.306c-1.225,-1.744 -2.882,-2.537 -4.957,-2.379c-0.145,-0.648 -0.433,-1.959 -0.576,-2.607l1.109,-0.332c0.057,-0.634 0.173,-1.902 0.216,-2.522c0.446,-1.614 0.922,-3.213 1.412,-4.799Z" style="fill-rule:nonzero;"/><path d="M440.204,604.558c4.51,-2.024 -0.217,4.9 0,0Z" style="fill-rule:nonzero;"/><path d="M461.529,619.04l0.419,-0.506l0.477,0.796l-0.636,0.143l-0.26,-0.433Z" style="fill-rule:nonzero;"/><path d="M464.571,621.776l0.347,0.304l0.289,0.376l-0.347,-0.304l-0.289,-0.376Z" style="fill-rule:nonzero;"/><path d="M461.487,624.702c0.361,0.418 0.361,0.418 0,0Z" style="fill-rule:nonzero;"/><path d="M467.741,624.976l0.26,0.246l-0.058,0.044c-0.058,-0.073 -0.159,-0.218 -0.202,-0.29Z" style="fill-rule:nonzero;"/><path d="M444.18,630.076c0.853,0.926 0.853,0.926 0,0Z" style="fill-rule:nonzero;"/><path d="M467.236,630.911l0.043,0.057l0.029,0.088c-0.014,-0.044 -0.058,-0.101 -0.072,-0.145Z" style="fill-rule:nonzero;"/><path d="M435.49,633.967c0.072,-0.028 0.231,-0.086 0.303,-0.116l-0.289,0.116l-0.014,0Z" style="fill-rule:nonzero;"/><path d="M441.486,633.087c0.867,0.911 0.867,0.911 0,0Z" style="fill-rule:nonzero;"/><path d="M447.249,633.116c0.925,0.867 0.925,0.867 0,0Z" style="fill-rule:nonzero;"/><path d="M453.259,633.015c0.867,0.926 0.867,0.926 0,0Z" style="fill-rule:nonzero;"/><path d="M432.465,636.317c0.925,0.867 0.925,0.867 0,0Z" style="fill-rule:nonzero;"/><path d="M461.919,635.739c0.462,0.39 0.462,0.39 0,0Z" style="fill-rule:nonzero;"/><path d="M478.981,636.026c0.824,0.766 0.824,0.766 0,0Z" style="fill-rule:nonzero;"/><path d="M459.195,638.997c0.911,0.838 0.911,0.838 0,0Z" style="fill-rule:nonzero;"/><path d="M476.098,639.054c0.824,0.766 0.824,0.766 0,0Z" style="fill-rule:nonzero;"/><path d="M481.878,638.925c0.781,0.809 0.781,0.809 0,0Z" style="fill-rule:nonzero;"/><path d="M499.153,638.997c0.824,0.765 0.824,0.765 0,0Z" style="fill-rule:nonzero;"/><path d="M484.73,641.878c0.809,0.78 0.809,0.78 0,0Z" style="fill-rule:nonzero;"/><path d="M490.508,641.762c0.824,0.766 0.824,0.766 0,0Z" style="fill-rule:nonzero;"/><path d="M496.359,641.733c0.795,0.81 0.795,0.81 0,0Z" style="fill-rule:nonzero;"/><path d="M487.77,644.846c0.81,0.781 0.81,0.781 0,0Z" style="fill-rule:nonzero;"/><path d="M493.519,644.73c0.795,0.81 0.795,0.81 0,0Z" style="fill-rule:nonzero;"/><path d="M499.182,644.716c0.766,0.825 0.766,0.825 0,0Z" style="fill-rule:nonzero;"/><path d="M496.315,647.64c0.838,0.752 0.838,0.752 0,0Z" style="fill-rule:nonzero;"/><path d="M459.052,650.638c0.376,0.376 0.376,0.376 0,0Z" style="fill-rule:nonzero;"/><path d="M522.368,667.872c0.752,0.781 0.752,0.781 0,0Z" style="fill-rule:nonzero;"/><path d="M541.964,668.94c0.983,0.809 0.983,0.809 0,0Z" style="fill-rule:nonzero;"/><path d="M524.73,674.673c3.602,-3.213 8.66,-3.126 13.156,-3.861c0.244,0.461 0.764,1.397 1.008,1.873c3.229,4.279 2.941,9.654 2.017,14.655c0.721,0.461 2.162,1.412 2.867,1.873c0.246,2.292 0.505,4.583 0.749,6.874c-7.334,1.57 -15.778,2.118 -21.484,-3.69c0.259,1.197 0.778,3.589 1.038,4.784c-4.337,-2.983 -6.47,-7.839 -8.487,-12.493c0.879,-3.242 2.089,-6.398 3.919,-9.237c-0.043,2.638 -0.101,5.333 -0.231,7.983c1.225,-3.227 2.45,-6.73 5.447,-8.762Z" style="fill-rule:nonzero;"/><path d="M592.328,684.388c1.397,-4.294 1.829,-9.006 4.711,-12.681c5.562,11.312 8.43,26.04 0.418,36.918c-0.332,0 -0.98,-0.015 -1.311,-0.029c-4.194,-3.444 -8.56,-6.829 -13.95,-8.142c4.626,-4.423 8.215,-9.957 10.131,-16.067Z" style="fill-rule:nonzero;"/><path d="M609.042,675.121l2.867,-0.721c3.978,7.205 7.565,14.857 8.215,23.185c2.767,22.321 -14.9,45.723 -37.638,47.538c-9.669,1.109 -20.16,-6.902 -19.123,-17.133c0.576,-11.168 14.05,-20.318 24.166,-14.078c6.311,3.415 7.58,11.095 5.634,17.478c8.488,-4.74 14.8,-12.782 18.372,-21.715c4.18,-11.255 1.975,-23.747 -2.493,-34.554Z" style="fill-rule:nonzero;"/><path d="M482.669,327.658c0.418,4.092 0.461,8.199 0.663,12.306c5.101,1.801 10.245,3.53 15.159,5.822c-5.144,1.657 -10.346,3.141 -15.519,4.712c0.216,3.055 0.461,6.124 0.735,9.179c0.288,3.17 0.562,6.34 0.98,9.51c-0.821,-2.651 -1.556,-5.303 -2.291,-7.968c-0.533,1.974 -1.081,3.948 -1.614,5.922c-2.277,-3.574 -4.885,-6.902 -7.392,-10.303c-0.476,-0.749 -1.398,-2.262 -1.873,-3.012l-0.331,-0.62c-5.476,1.383 -10.995,2.565 -16.586,3.343c1.873,-2.234 3.847,-4.366 5.85,-6.499c1.801,-1.96 3.574,-3.963 5.346,-5.951c-1.787,-2.896 -3.66,-5.735 -5.303,-8.704c-0.23,-0.62 -0.706,-1.859 -0.937,-2.478c4.51,0.994 8.862,2.579 13.401,3.401c3.646,-2.392 6.556,-5.692 9.712,-8.66Zm-21.6,25.462c3.848,-0.303 7.652,-0.908 11.47,-1.484c2.147,3.819 4.568,7.479 7.464,10.779c0.922,-4.337 1.398,-8.747 1.715,-13.171c4.15,-1.167 8.415,-2.046 12.248,-4.121c-11.369,-5.519 -24.122,0.403 -32.898,7.997Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M649.435,426.192c3.473,-1.47 5.144,5.072 1.369,5.461c-3.214,0.879 -4.799,-4.64 -1.369,-5.461Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M532.441,431.719c0.361,-0.679 1.099,-2.024 1.46,-2.689l1.041,0.347c0.303,2.009 -0.535,2.775 -2.501,2.342Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M483.433,448.974c1.859,-0.98 3.689,-2.032 5.605,-2.896c0.663,1.283 1.282,2.579 1.873,3.919l-1.095,1.081c-1.989,2.536 -4.539,-0.994 -6.384,-2.104Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M485.307,527.608c1.138,0.202 3.415,0.591 4.554,0.778c-0.821,1.586 -1.326,3.848 -3.631,3.113c-0.231,-0.966 -0.692,-2.911 -0.922,-3.891Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M654.883,535.089c1.403,-2.312 4.799,0.376 3.31,2.415c-1.445,2.298 -4.929,-0.332 -3.31,-2.415Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M678.181,535.432c1.599,-0.533 3.602,1.399 2.954,2.941c-0.158,1.93 -3.04,2.651 -4.136,1.311c-1.296,-1.268 -0.894,-4.035 1.182,-4.251Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M671.409,546.283c0.705,-3.804 6.311,-0.936 3.846,1.874c-1.368,1.368 -4.149,0.072 -3.846,-1.874Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M668.009,552.177c1.167,0.922 2.32,1.845 3.502,2.796c-1.182,1.037 -2.364,2.06 -3.545,3.084c-0.029,-1.96 -0.015,-3.921 0.043,-5.88Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M633.381,569.585c2.651,0.402 5.303,0.835 7.969,1.296c-0.98,3.314 -3.66,4.727 -6.974,4.409l-0.303,-1.773c-0.231,-1.311 -0.462,-2.636 -0.692,-3.933Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M653.374,578.52c2.63,-0.044 1.619,3.86 -0.491,3.801c-2.616,0.058 -1.692,-3.961 0.491,-3.801Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M637.143,582.61c1.628,-0.433 3.271,-0.822 4.942,-1.167c0.995,2.608 0.475,4.87 -2.017,6.268c-0.173,-2.146 -1.153,-3.846 -2.925,-5.1Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M635.037,589.296c1.716,0.894 3.805,2.176 2.998,4.481c-0.057,2.796 -3.358,2.061 -5.188,2.695c0.591,-2.436 1.369,-4.813 2.19,-7.176Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M562.47,626.576c4.064,6.974 4.251,15.577 2.695,23.3c-1.643,2.926 -3.486,5.75 -5.634,8.33c-4.986,2.262 -5.878,7.91 -8.371,12.219c1.094,-3.214 2.493,-6.297 3.919,-9.366c-4.741,0.547 -9.324,-0.822 -12.724,-4.208c3.314,0.375 6.541,1.311 9.812,1.93c2.998,0.189 5.346,-2.089 7.622,-3.688c0.98,-1.542 1.931,-3.084 2.796,-4.668c0.505,-1.513 0.951,-3.041 1.355,-4.569c0.303,-1.902 0.534,-3.818 0.707,-5.72c-0.216,-4.582 -1.067,-9.107 -2.177,-13.559Z" style="fill:#b2c7e5;fill-rule:nonzero;"/><path d="M547.643,335.597c9.654,-6.268 22.191,-9.611 33.359,-5.562c9.568,3.127 18.934,9.597 22.378,19.453c4.828,12.335 0.13,27.523 -10.879,34.901c-11.154,8.487 -27.509,6.47 -37.71,-2.579c-3.978,7.551 -4.885,16.571 -2.594,24.799c1.21,4.755 3.631,9.078 5.173,13.733l0.215,0.591c-1.065,-0.677 -3.818,-4.726 -4.336,-1.398c-1.47,2.104 1.743,4.121 1.052,6.355c-1.153,0.375 -2.349,0.562 -3.574,0.548c0.72,-0.836 2.133,-2.478 2.853,-3.3c-1.181,0 -3.516,0.014 -4.698,0.014c0.837,-0.807 2.493,-2.435 3.328,-3.257c-0.993,0.173 -2.968,0.504 -3.947,0.677c0.202,-0.519 0.591,-1.556 0.792,-2.075c-7.218,-11.571 -9.884,-26.067 -5.418,-39.166c0.303,-0.576 0.908,-1.729 1.21,-2.306c1.009,-2.19 2.017,-4.395 3.113,-6.556c0.274,-0.403 0.835,-1.21 1.124,-1.628c2.81,5.894 8.531,9.28 14.438,11.47c0.288,-0.692 0.865,-2.075 1.153,-2.767c-0.143,0.807 -0.417,2.406 -0.547,3.213c8.934,1.556 18.719,-0.893 24.742,-7.954l0.432,2.493c-0.216,0.216 -0.663,0.663 -0.893,0.893c-1.903,1.383 -3.833,2.738 -5.837,3.992c-8.588,4.798 -18.574,2.435 -27.191,-0.908c7.681,8.891 20.909,10.721 31.5,6.484c0.26,-0.533 0.778,-1.614 1.039,-2.147c0.301,-0.202 0.907,-0.62 1.225,-0.821c0.244,0.144 0.764,0.447 1.023,0.591c15.563,-8.156 18.012,-32.667 4.222,-43.662c1.773,4.827 4.15,9.683 3.862,14.972c0.129,5.346 -2.349,10.188 -3.761,15.217c-0.966,0.504 -2.897,1.528 -3.862,2.046c0.303,-0.865 0.923,-2.565 1.226,-3.415c6.873,-8.718 6.109,-22.407 -1.816,-30.217c-5.072,-4.914 -11.903,-8.66 -19.137,-8.286c-13.069,0.36 -25.664,7.248 -33.732,17.407c-1.441,1.225 -3.2,2.536 -3.272,4.611c-0.562,0.504 -1.671,1.528 -2.234,2.046c-1.772,2.478 -3.17,5.187 -4.265,8.026l-0.49,0.994c-1.484,2.565 -2.651,5.332 -3.012,8.314c-0.864,6.528 -2.795,12.954 -1.801,19.612c-4.035,-1.744 -1.441,-7.709 -1.729,-11.211c0.793,-9.366 4.986,-17.883 9.41,-26.01c1.11,-1.917 2.133,-3.905 3.113,-5.908c5.116,-3.876 9.237,-8.963 14.755,-12.32Zm1.052,79.902c0.547,3.574 3.934,0.836 5.779,0.288c-6.959,-11.254 -7.148,-26.399 0.345,-37.437c-1.931,-1.787 -3.861,-3.588 -5.75,-5.404c-8.472,12.652 -7.046,29.439 -0.375,42.552Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M538.003,350.642c8.531,-8.444 18.834,-16.514 31.385,-16.932c12.276,-1.196 25.001,9.611 23.329,22.436c-0.303,9.064 -6.873,15.908 -13.906,20.765c-6.023,0.663 -12.652,1.398 -17.911,-2.306c-5.447,-3.098 -8.041,-9.107 -9.12,-15.015c-1.658,2.161 -3.3,4.337 -4.813,6.6c-0.332,0.648 -1.009,1.96 -1.355,2.608c-1.268,1.945 -2.594,3.847 -3.919,5.749c-1.081,1.138 -1.499,2.479 -1.268,4.02c-2.363,6.715 -3.099,13.848 -2.421,20.923c-2.983,-1.182 -1.685,-4.856 -1.974,-7.277c0.619,-14.367 7.839,-28.258 18.761,-37.523c-3.286,10.404 5.447,21.226 16.067,21.24c2.739,0.475 4.914,-1.196 6.686,-3.069c1.052,0.144 2.118,0.288 3.185,0.447c1.47,-1.297 2.941,-2.579 4.338,-3.963c0.158,-0.173 0.49,-0.519 0.648,-0.692c4.957,-5.548 6.946,-13.905 4.15,-20.909c-1.917,-5.245 -6.673,-8.948 -11.686,-11.023c-6.715,-2.608 -14.223,-0.965 -20.65,1.7c-12.522,5.591 -22.132,16.831 -26.902,29.555c-0.634,1.96 -1.225,3.919 -1.888,5.85l-2.032,1.527c0.331,-1.124 0.98,-3.372 1.297,-4.496c0.547,-1.772 1.009,-3.574 1.455,-5.375c0.346,-0.692 1.052,-2.075 1.398,-2.767c1.153,-2.032 2.248,-4.092 3.257,-6.211c1.21,-0.908 2.335,-2.291 1.902,-3.905c0.505,-0.49 1.485,-1.47 1.988,-1.96Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M414.469,547.522c0.72,-1.628 2.176,-4.482 4.352,-3.978c1.254,2.291 -2.406,4.338 -4.352,3.978Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M422.565,543.431c2.277,-1.325 3.703,2.017 5.115,3.285c-0.346,0.274 -1.037,0.837 -1.383,1.11c-1.816,-0.418 -4.842,-2.162 -3.732,-4.395Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M407.55,574.426c0.951,0.013 2.853,0.028 3.804,0.028c6.845,1.312 11.297,7.163 15.663,12.105c1.412,1.195 0.029,2.608 -0.504,3.848c0.432,1.715 0.879,3.444 1.081,5.23c-0.216,0.404 -0.663,1.211 -0.893,1.599c0.879,1.067 1.744,2.162 2.594,3.271c-3.285,3.559 -4.64,-5.317 -6.859,-0.619c0.893,0.303 2.695,0.922 3.602,1.239c-0.288,0.72 -0.865,2.161 -1.153,2.882c-2.248,-1.283 -4.078,-3.156 -5.98,-4.857c-2.882,-0.865 -3.242,-4.437 -5.822,-5.735c-3.069,-1.555 -4.366,-5.188 -7.205,-6.844c-0.476,0.764 -1.427,2.276 -1.902,3.026c-2.651,-1.456 0.115,-3.126 1.859,-3.617c-0.951,-1.485 -2.147,-2.767 -3.343,-4.035c1.138,-0.533 2.277,-1.052 3.415,-1.557l-1.081,-2.506c0.778,-0.015 2.32,-0.058 3.098,-0.073c-0.086,-0.85 -0.274,-2.535 -0.375,-3.385Zm3.761,6.829c4.236,-2.363 0.389,4.771 0,0Zm-3.026,3.185c4.035,-2.608 0.562,4.784 0,0Zm15.62,8.459c4.784,-0.116 -1.917,4.437 0,0Zm-3.689,2.695c-0.692,1.988 -0.014,2.752 2.017,2.306c0.677,-2.004 0,-2.768 -2.017,-2.306Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M446.934,618.418c4.814,0.348 -2.399,4.193 0,0Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M464.268,647.47c4.25,-2.27 0.246,4.828 0,0Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M450.149,661.619c4.799,0.376 -2.385,4.206 0,0Z" style="fill:#a9a9aa;fill-rule:nonzero;"/><path d="M460.492,335.395c1.643,2.968 3.516,5.807 5.303,8.704c-1.772,1.989 -3.545,3.991 -5.346,5.951c-0.072,-4.885 -0.937,-9.813 0.043,-14.655Z" style="fill:#51667b;fill-rule:nonzero;"/><path d="M461.069,353.121c8.776,-7.594 21.528,-13.516 32.898,-7.997c-3.833,2.075 -8.098,2.954 -12.248,4.121c-0.317,4.424 -0.793,8.833 -1.715,13.171c-2.896,-3.3 -5.317,-6.96 -7.464,-10.779c-3.819,0.576 -7.623,1.182 -11.47,1.484Z" style="fill:#4c71b3;fill-rule:nonzero;"/><path d="M525.207,343.523c2.342,-0.781 3.151,0 2.414,2.371c-2.356,0.781 -3.166,-0.014 -2.414,-2.371Z" style="fill:#2d7eab;fill-rule:nonzero;"/><path d="M482.971,350.496c5.173,-1.571 10.375,-3.055 15.519,-4.712c-0.62,2.911 -1.182,5.822 -1.686,8.747c-4.164,2.176 -8.559,3.876 -13.098,5.144c-0.274,-3.055 -0.519,-6.124 -0.735,-9.179Z" style="fill:#5d83ab;fill-rule:nonzero;"/><path d="M533.895,352.054c0.072,-2.075 1.83,-3.386 3.271,-4.611c0.202,0.793 0.619,2.406 0.835,3.199c-0.505,0.49 -1.485,1.47 -1.988,1.96c-0.534,-0.144 -1.586,-0.418 -2.119,-0.547Z" style="fill:#2a95cd;fill-rule:nonzero;"/><path d="M517.051,353.278c-0.548,-4.798 7.407,-6.038 8.459,-1.513c0.029,0.994 0.101,3.012 0.13,4.006c1.038,-0.475 3.098,-1.455 4.136,-1.945c-4.424,8.127 -8.617,16.643 -9.41,26.01c-1.542,-0.922 -4.15,-1.052 -4.28,-3.357c-0.922,-3.847 4.294,-4.698 4.481,-8.142c-0.014,-4.337 3.833,-7.046 5.303,-10.851c-3.401,0.288 -9.078,0.807 -8.819,-4.208Z" style="fill:#2095d2;fill-rule:nonzero;"/><path d="M513.867,358.395c3.127,-2.594 8.286,1.196 6.643,4.928c-0.735,2.781 -4.51,3.833 -6.7,2.089c-2.378,-1.556 -2.306,-5.49 0.058,-7.018Z" style="fill:#2095d2;fill-rule:nonzero;"/><path d="M534.992,360.901c1.758,-3.732 8.501,-1.902 8.084,2.306c0.604,4.063 -3.921,4.943 -6.961,4.914c-0.865,-2.334 -3.53,-4.928 -1.124,-7.219Z" style="fill:#2095d2;fill-rule:nonzero;"/><path d="M530.625,367.976c4.136,-2.133 9.713,2.219 6.787,6.585c-2.132,3.473 -6.47,1.643 -8.674,-0.735c0.663,-1.931 1.254,-3.891 1.888,-5.85Z" style="fill:#2095d2;fill-rule:nonzero;"/><path d="M614.289,349.287c2.356,-0.636 3.137,0.217 2.327,2.53c-2.385,0.651 -3.165,-0.188 -2.327,-2.53Z" style="fill:#266f96;fill-rule:nonzero;"/><path d="M498.839,350.815c4.785,0.246 -2.226,4.322 0,0Z" style="fill:#27495e;fill-rule:nonzero;"/><path d="M542.772,352.817c2.594,-1.499 4.611,3.473 1.599,4.064c-2.407,1.297 -4.38,-3.257 -1.599,-4.064Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M588.868,372.804c0.317,-1.859 1.312,-3.271 2.983,-4.265c-0.301,0.85 -0.922,2.551 -1.225,3.415c0.966,-0.519 2.897,-1.542 3.862,-2.046c0.057,3.242 -2.608,4.424 -5.188,5.389l-0.433,-2.493Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M525.842,383.626c-3.026,-0.375 -2.839,-6.874 0.461,-5.663c0.403,1.888 0.144,3.833 -0.461,5.663Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M582.572,380.183c2.003,-1.254 3.934,-2.608 5.836,-3.991c0.837,2.133 1.081,4.323 0.736,6.6c-0.317,0.202 -0.923,0.62 -1.226,0.821c-2.434,0.029 -4.063,-1.542 -5.346,-3.43Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M623.452,420.745l0.85,-1.412c0.317,2.046 0.663,4.107 0.923,6.167c0.562,6.167 0.446,12.364 0.475,18.545c-0.029,6.917 -0.822,13.79 -2.017,20.592c-1.268,7.896 -3.473,15.591 -6.383,23.026l-1.053,-0.806c1.889,-10.174 4.209,-20.274 5.188,-30.606c0.418,-1.182 0.808,-2.378 1.182,-3.574c2.003,-8.487 1.672,-17.522 -0.044,-26.024c-0.028,-0.245 -0.072,-0.735 -0.1,-0.98c0.23,-1.657 0.547,-3.314 0.979,-4.928Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M494.371,479.306l2.075,-1.037c-1.801,4.395 -2.032,9.179 -1.354,13.833c0.504,-3.299 0.864,-6.614 1.398,-9.913c2.19,6.368 4.784,12.623 8.329,18.372c-0.865,0.116 -2.594,0.375 -3.458,0.505c-4.496,-1.628 -7.176,-5.922 -8.675,-10.245c-0.014,-1.917 0.029,-3.833 0.101,-5.75c0.375,-1.946 0.562,-4.02 1.585,-5.764Zm2.017,14.179c0.735,0.749 0.735,0.749 0,0Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M471.617,521.154c2.219,-0.865 4.467,-1.744 6.657,-2.709c-0.922,1.786 -1.845,3.587 -2.795,5.375c-1.225,0.663 -2.45,1.325 -3.675,1.974c-5.159,2.306 -9.971,5.333 -14.093,9.193l-0.548,0.086c-2.176,0.202 -4.366,0.361 -6.542,0.49c1.239,-1.542 2.478,-3.084 3.804,-4.553c2.637,-2.003 5.447,-3.747 8.314,-5.404c2.882,-1.628 5.822,-3.156 8.876,-4.452Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M685.427,541.269c2.19,-2.825 5.908,1.34 3.574,3.299c-1.715,2.638 -6.111,-1.21 -3.574,-3.299Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M669.868,566.011c1.656,0.116 3.285,0.231 4.942,0.375c0,1.182 -0.029,3.53 -0.043,4.712c-1.542,0.044 -3.084,0.259 -4.597,-0.015c-1.428,-1.34 -0.317,-3.429 -0.303,-5.072Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M696.451,582.508c1.484,-2.017 4.927,0.389 3.343,2.421c-1.441,2.234 -5.188,-0.331 -3.343,-2.421Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M642.143,596.877c2.356,-0.752 3.15,0.058 2.371,2.429c-2.343,0.693 -3.137,-0.116 -2.371,-2.429Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M637.226,601.53c3.805,0.361 6.788,3.271 5.692,7.35c-2.508,-1.917 -4.309,-4.554 -5.692,-7.35Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M624.681,608.618c1.936,0.926 1.936,1.879 0.015,2.891c-1.894,-0.939 -1.909,-1.894 -0.015,-2.891Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M646.004,609.63c2.089,-1.889 4.927,1.584 3.299,3.573c-1.843,2.133 -4.784,-1.786 -3.299,-3.573Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M454.598,356.549c5.591,-0.778 11.11,-1.96 16.586,-3.343l0.331,0.62c-0.115,3.487 -0.36,6.974 -0.605,10.447c-4.078,4.871 -7.752,10.159 -12.926,13.963c-1.599,-7.147 -2.536,-14.424 -3.386,-21.687Z" style="fill:#6497ca;fill-rule:nonzero;"/><path d="M527.397,362.126c1.095,-2.839 2.493,-5.548 4.265,-8.026c0.605,0.591 1.83,1.801 2.45,2.406c-1.009,2.118 -2.104,4.179 -3.257,6.211c-0.865,-0.144 -2.594,-0.447 -3.458,-0.591Z" style="fill:#6497ca;fill-rule:nonzero;"/><path d="M523.895,371.435c0.36,-2.983 1.527,-5.75 3.012,-8.314c0.649,0.591 1.917,1.772 2.551,2.363c-0.447,1.801 -0.908,3.602 -1.455,5.375c-1.023,0.144 -3.084,0.432 -4.107,0.576Z" style="fill:#6497ca;fill-rule:nonzero;"/><path d="M541.691,374.547c0.778,0.62 2.364,1.859 3.156,2.478c-0.303,0.576 -0.907,1.729 -1.21,2.306c-0.808,-0.187 -2.421,-0.576 -3.214,-0.764c-0.23,-1.542 0.189,-2.882 1.268,-4.02Z" style="fill:#6497ca;fill-rule:nonzero;"/><path d="M694.768,546.673c2.284,-0.794 3.093,-0.015 2.429,2.356c-2.328,0.823 -3.152,0.044 -2.429,-2.356Z" style="fill:#6497ca;fill-rule:nonzero;"/><path d="M540.999,654.011c6.282,-0.317 12.536,0.562 18.791,1.081c-2.276,1.599 -4.626,3.876 -7.624,3.688c-3.27,-0.619 -6.499,-1.557 -9.812,-1.931l-0.274,-0.215c-0.274,-0.649 -0.808,-1.96 -1.081,-2.623Z" style="fill:#6497ca;fill-rule:nonzero;"/><path d="M471.516,353.827c0.476,0.749 1.398,2.262 1.873,3.012c-0.634,6.6 -0.072,13.243 1.7,19.64c-3.919,5.548 -8.329,10.822 -13.617,15.102c-1.297,-4.409 -2.507,-8.848 -3.487,-13.343c5.173,-3.804 8.848,-9.093 12.926,-13.963c0.245,-3.473 0.49,-6.96 0.605,-10.447Z" style="fill:#608bb6;fill-rule:nonzero;"/><path d="M483.707,359.675c4.539,-1.268 8.934,-2.968 13.098,-5.144c-0.331,4.899 -0.418,9.799 -0.389,14.712c-3.386,1.398 -6.801,2.738 -10.231,4.006c-0.245,-0.62 -0.749,-1.873 -0.994,-2.507c-0.13,-0.389 -0.375,-1.167 -0.504,-1.556c-0.418,-3.17 -0.692,-6.34 -0.98,-9.51Z" style="fill:#597a9c;fill-rule:nonzero;"/><path d="M650.124,437.722c3.041,0.922 6.313,1.628 8.877,3.646c1.067,2.536 -0.231,5.461 -0.288,8.156c-0.649,2.334 -1.355,4.654 -2.061,6.989c-2.032,-2.435 -4.927,-3.588 -7.91,-4.337c1.124,-4.741 1.239,-9.611 1.383,-14.453Z" style="fill:#597a9c;fill-rule:nonzero;"/><path d="M473.388,356.838c2.507,3.401 5.115,6.729 7.392,10.303c1.398,5.058 2.147,10.26 3.113,15.418c-2.262,0.908 -4.366,2.147 -6.369,3.516c-0.908,-3.17 -1.7,-6.384 -2.435,-9.597c-1.772,-6.398 -2.334,-13.041 -1.7,-19.64Z" style="fill:#53708c;fill-rule:nonzero;"/><path d="M443.272,421.768c2.795,-1.989 5.605,-3.977 8.415,-5.951c2.723,4.842 5.591,9.64 8.949,14.078c-1.902,2.334 -3.804,4.669 -5.706,7.003c-4.481,-4.568 -7.666,-10.159 -11.657,-15.13Z" style="fill:#53708c;fill-rule:nonzero;"/><path d="M482.396,361.219c0.735,2.666 1.47,5.317 2.291,7.969c0.13,0.389 0.375,1.167 0.504,1.556c-1.196,1.556 -2.133,8.026 1.254,5.778c0.014,0.461 0.058,1.369 0.072,1.816c-0.288,7.219 2.363,14.021 4.467,20.779c0.879,2.623 1.744,5.389 3.775,7.392c0.403,0.475 1.239,1.426 1.657,1.902c-0.317,0.994 -0.98,2.997 -1.297,3.991c-0.202,0.432 -0.62,1.297 -0.821,1.729c-4.64,-10.087 -8.242,-20.678 -10.404,-31.572c-0.966,-5.159 -1.715,-10.361 -3.113,-15.418c0.533,-1.974 1.081,-3.948 1.614,-5.922Z" style="fill:#7199c2;fill-rule:nonzero;"/><path d="M581.461,364c0.908,-0.115 2.709,-0.346 3.617,-0.461c0.159,1.283 0.475,3.833 0.635,5.115c-0.159,0.173 -0.49,0.519 -0.648,0.692c-3.545,1.124 -6.399,-2.695 -3.603,-5.346Z" style="fill:#227cad;fill-rule:nonzero;"/><path d="M545.611,368.798c0.345,-0.649 1.023,-1.96 1.355,-2.608c0.533,0.663 1.584,1.989 2.117,2.651c-0.288,0.418 -0.85,1.225 -1.124,1.628c-0.591,-0.418 -1.757,-1.254 -2.348,-1.671Z" style="fill:#226f99;fill-rule:nonzero;"/><path d="M486.184,373.252c3.43,-1.268 6.845,-2.608 10.231,-4.006c0.36,5.361 1.066,10.678 1.859,15.981c-2.925,1.888 -5.865,3.746 -8.819,5.62c-1.167,-4.121 -2.089,-8.314 -2.94,-12.508c-0.014,-0.447 -0.058,-1.355 -0.072,-1.816c-0.072,-0.821 -0.202,-2.45 -0.259,-3.271Z" style="fill:#526d88;fill-rule:nonzero;"/><path d="M509.485,369.432c1.383,-2.104 5.332,0.072 3.502,2.262c-1.427,1.917 -5.173,-0.144 -3.502,-2.262Z" style="fill:#23678c;fill-rule:nonzero;"/><path d="M486.443,376.522c-3.386,2.248 -2.45,-4.222 -1.254,-5.778c0.245,0.634 0.749,1.888 0.994,2.507c0.058,0.821 0.187,2.45 0.259,3.271Z" style="fill:#eef1f2;fill-rule:nonzero;"/><path d="M622.574,426.653c1.715,8.502 2.045,17.537 0.043,26.024c0.404,-5.461 0.448,-10.923 0.418,-16.37c-0.086,-3.228 -0.231,-6.456 -0.461,-9.655Z" style="fill:#eef1f2;fill-rule:nonzero;"/><path d="M503.852,471.063c9.266,-4.942 19.799,-7.42 30.318,-6.469c6.052,0.734 12.047,1.931 17.94,3.516c-4.567,-0.663 -9.122,-1.484 -13.689,-2.146c-3.905,-0.591 -7.853,-0.75 -11.787,-0.505c-3.141,0.303 -6.268,0.764 -9.352,1.412c-5.548,1.557 -10.663,4.395 -15.188,7.925c-2.248,2.148 -4.121,4.67 -5.605,7.392c-0.533,3.3 -0.893,6.614 -1.398,9.915c-0.677,-4.655 -0.447,-9.439 1.354,-13.834c2.608,-2.247 4.971,-4.769 7.407,-7.205Z" style="fill:#eef1f2;fill-rule:nonzero;"/><path d="M550.395,482.894c2.695,-0.475 5.39,-0.907 8.112,-1.21c7.104,0.259 14.295,0.634 21.155,2.607c4.799,1.586 9.856,3.17 14.842,1.312c5.576,3.242 10.923,6.902 15.562,11.398c-0.375,0.806 -1.138,2.434 -1.513,3.242c4.237,5.951 8.876,11.743 11.672,18.56c2.017,4.929 3.286,10.116 4.885,15.189c0.274,0.993 0.835,2.968 1.11,3.962c-3.559,0.721 -7.191,0.936 -10.779,0.332c-1.729,-11.932 -7.58,-23.402 -16.701,-31.342c1.658,-0.634 3.3,-1.254 4.986,-1.83c0.448,-0.966 0.879,-1.946 1.327,-2.911c-1.802,-1.08 -3.387,-3.602 -5.75,-2.392c-0.549,2.292 -0.677,4.655 -0.808,7.003c-13.948,-13.992 -36.01,-17.998 -54.542,-11.931c1.542,-1.946 2.205,-4.121 2.004,-6.57c-0.534,-0.275 -1.586,-0.837 -2.104,-1.11c-0.202,-0.72 -0.576,-2.133 -0.765,-2.838c2.421,-0.505 4.857,-1.024 7.306,-1.47Z" style="fill:#eef1f2;fill-rule:nonzero;"/><path d="M549.501,533.068c4.309,-3.112 9.338,-5.202 14.728,-5.202c5.922,1.24 11.167,4.771 14.308,9.972c1.34,3.862 1.96,7.954 1.268,12.018c-0.259,1.182 -0.533,2.377 -0.793,3.587l-0.086,-1.181c-0.158,-3.055 -0.244,-6.096 -0.49,-9.136c-1.873,-4.107 -4.351,-8.199 -8.386,-10.475c-4.136,-2.682 -9.223,-2.594 -13.935,-3.084l0.029,0.85c-1.658,0.648 -3.286,1.296 -4.914,2.003l-1.729,0.648Z" style="fill:#eef1f2;fill-rule:nonzero;"/><path d="M657.747,375.225c0.781,0.752 0.781,0.752 0,0Z" style="fill:#265570;fill-rule:nonzero;"/><path d="M475.09,376.478c0.735,3.213 1.528,6.427 2.435,9.597c1.066,3.588 2.205,7.147 3.357,10.706c-1.326,-0.231 -2.637,-0.49 -3.977,-0.605c-3.415,3.473 -6.182,7.536 -9.669,10.937c-2.104,-5.101 -4.107,-10.26 -5.764,-15.534c5.288,-4.28 9.698,-9.554 13.617,-15.101Z" style="fill:#5b7ea2;fill-rule:nonzero;"/><path d="M526.086,375.902l1.96,-1.11c-0.331,3.559 -0.692,7.133 -0.879,10.707c-0.115,2.32 -0.101,4.654 -0.187,6.989c-2.003,-2.493 -1.888,-5.937 -1.138,-8.862c0.605,-1.83 0.865,-3.775 0.461,-5.663l-0.216,-2.06Z" style="fill:#b1b1b2;fill-rule:nonzero;"/><path d="M514.112,530.232c7.176,-20.808 29.136,-35.463 51.154,-33.517c15.418,0.979 30.059,9.682 38.547,22.551c8.156,11.859 10.302,27.277 6.743,41.126c-4.61,14.741 -15.288,27.825 -29.928,33.387c-3.53,1.571 -7.752,1.7 -10.866,4.05c4.309,1.296 8.617,-0.879 12.595,-2.349c7.608,-2.982 13.847,-8.371 20.26,-13.3c3.128,2.089 6.471,3.833 9.957,5.259c-3.472,4.382 -7.767,8.013 -12.061,11.558c-3.04,-1.571 -5.937,-3.761 -9.351,-4.382c-3.978,1.024 -7.565,3.128 -11.398,4.51c-3.89,1.557 -8.041,2.379 -12.191,2.796c-1.599,-0.562 -3.905,-0.216 -4.942,-1.7c0.764,-1.801 2.464,-2.262 5.115,-1.426l-0.013,-0.246c-1.514,-1.397 -8.316,-0.013 -5.836,-3.558c10.433,-1.268 20.952,-4.281 29.222,-11.053c6.557,-5.346 11.629,-12.435 14.627,-20.333c3.184,-8.775 2.146,-18.227 1.772,-27.349c-3.286,-11.211 -9.943,-21.759 -19.957,-28.07c-16.413,-11.095 -39.584,-9.799 -55.176,2.219c-12.594,9.684 -19.208,26.139 -17.076,41.861l2.161,-0.073l1.182,-0.072c1.081,-0.072 3.242,-0.244 4.323,-0.331c3.948,0.835 7.825,2.045 11.657,3.314c3.012,2.897 6.311,5.706 8.373,9.41c0.936,2.075 -1.037,3.804 -1.788,5.576c-2.031,-1.656 -4.135,-3.213 -6.326,-4.61c1.226,-1.226 2.436,-2.45 3.66,-3.66c-7.766,-8.689 -20.534,-8.127 -31.168,-7.45c0.389,-0.145 1.167,-0.433 1.556,-0.591c0.562,-0.216 1.671,-0.677 2.219,-0.908c-0.187,-7.637 0.115,-15.446 2.954,-22.637Z" style="fill:#b1b1b2;fill-rule:nonzero;"/><path d="M438.635,661.678c4.799,0.333 -2.269,4.265 0,0Z" style="fill:#b1b1b2;fill-rule:nonzero;"/><path d="M528.046,374.792c2.464,1.686 6.081,3.602 4.813,7.248c-0.375,2.795 -3.689,2.695 -5.692,3.458c0.187,-3.574 0.548,-7.147 0.879,-10.707Z" style="fill:#2896d1;fill-rule:nonzero;"/><path d="M490.984,399.117c-2.104,-6.758 -4.755,-13.56 -4.467,-20.779c0.85,4.193 1.772,8.387 2.94,12.508c1.571,5.274 3.199,10.562 5.303,15.663c-2.032,-2.003 -2.896,-4.77 -3.775,-7.392Z" style="fill:#eff1f3;fill-rule:nonzero;"/><path d="M497.627,558.576c2.334,-1.283 4.755,-2.392 7.248,-3.33c0.62,-0.23 1.873,-0.663 2.507,-0.878c10.634,-0.677 23.401,-1.24 31.168,7.45c-1.225,1.21 -2.434,2.434 -3.66,3.66c-17.968,-10.794 -43.964,1.037 -47.509,21.715c-1.816,0.417 -3.602,1.469 -5.505,1.081c-0.231,-10.88 7.234,-19.352 13.012,-27.769c0.893,-0.677 1.801,-1.325 2.738,-1.93Z" style="fill:#eff1f3;fill-rule:nonzero;"/><path d="M647.098,566.731c0.49,-0.547 1.47,-1.628 1.96,-2.176c0.619,0.057 1.858,0.173 2.478,0.231c0.375,0.316 1.109,0.951 1.484,1.282c0.347,0.389 1.039,1.197 1.384,1.599l-1.369,-0.878c-0.893,1.166 -1.801,2.334 -2.695,3.501c1.167,-2.133 -1.138,-4.237 -3.242,-3.559Z" style="fill:#eff1f3;fill-rule:nonzero;"/><path d="M477.525,386.075c2.003,-1.369 4.107,-2.608 6.369,-3.516c2.162,10.894 5.764,21.485 10.404,31.572c0.202,-0.432 0.62,-1.297 0.821,-1.729l1.556,1.499l0.836,1.355c-4.193,-1.254 -3.458,3.473 -3.141,5.706c1.96,9.136 6.182,17.695 11.153,25.577c-1.729,-0.231 -3.444,-0.447 -5.159,-0.663c-1.83,2.248 -3.646,4.51 -5.418,6.801c-0.461,-0.576 -1.412,-1.744 -1.873,-2.32c-0.101,-0.504 -0.288,-1.499 -0.375,-2.003c-0.447,0.403 -1.34,1.225 -1.787,1.643c-0.591,-1.34 -1.21,-2.637 -1.873,-3.919c-1.254,-2.479 -2.723,-4.842 -4.251,-7.147c2.94,-2.262 5.764,-4.654 8.487,-7.176c0.721,0.115 2.147,0.36 2.867,0.49c-3.386,-6.888 -6.744,-13.79 -10.015,-20.736c-2.075,-4.784 -3.66,-9.756 -5.245,-14.727c-1.153,-3.559 -2.291,-7.118 -3.357,-10.706Z" style="fill:#435668;fill-rule:nonzero;"/><path d="M630.326,382.501c0.823,0.636 0.823,0.636 0,0Z" style="fill:#243846;fill-rule:nonzero;"/><path d="M498.275,385.225l0.216,-0.13c0.807,4.726 2.637,9.251 3.069,14.035c-1.484,3.213 -3.545,6.124 -5.144,9.28c-0.418,-0.476 -1.254,-1.427 -1.657,-1.902c-2.104,-5.101 -3.732,-10.389 -5.303,-15.663c2.954,-1.873 5.894,-3.732 8.819,-5.62Z" style="fill:#496074;fill-rule:nonzero;"/><path d="M614.289,388.409c3.314,1.657 6.614,3.386 9.885,5.159c3.371,-2.032 6.728,-4.092 10.201,-5.951c0.044,3.602 -0.634,7.162 -1.067,10.721c3.963,1.542 6.456,5.418 10.245,7.392c-4.813,0.576 -9.625,1.009 -14.438,1.383c-1.239,4.208 -2.349,8.531 -4.813,12.22l-0.85,1.412c-0.432,1.614 -0.749,3.271 -0.98,4.928c-0.215,-2.017 -0.417,-4.02 -0.604,-6.023c-0.347,-4.409 -1.312,-8.718 -2.493,-12.954c-4.352,-0.418 -8.733,-0.36 -13.056,0.331l-0.793,0.101c3.214,-3.43 7.277,-5.85 11.125,-8.473c-0.778,-3.429 -1.584,-6.83 -2.363,-10.245Zm-4.136,16.528c3.271,0.591 7.161,-0.418 10.115,1.282c1.557,3.127 2.349,6.657 4.51,9.496c1.197,-3.53 2.392,-7.075 3.717,-10.562c3.128,-0.101 6.269,-0.245 9.395,-0.49c-8.228,-4.409 -20.432,-7.075 -27.738,0.274Z" style="fill:#b0c6e5;fill-rule:nonzero;"/><path d="M503.089,400.788c4.971,-6.499 13.416,-9.366 21.413,-8.228l-0.086,0.173c-6.067,13.79 -13.055,27.263 -17.854,41.572c-5.418,-8.257 -8.415,-19.511 -3.113,-28.56c2.867,-5.663 8.776,-8.415 14.323,-10.793c-7.868,0.49 -13.747,6.398 -17.393,12.897c-1.383,-2.795 1.34,-4.986 2.709,-7.061Z" style="fill:#f8cb25;fill-rule:nonzero;"/><path d="M499.572,406.061l0.807,1.787c-0.864,4.381 -1.974,8.862 -1.153,13.343c0.793,6.182 4.337,11.441 7.176,16.845c-3.718,-1.585 -5.115,-5.692 -6.816,-9.021c-2.089,-4.28 -2.723,-9.064 -2.075,-13.761l-0.836,-1.355c0.908,-2.637 2.075,-5.173 2.896,-7.839Z" style="fill:#f8cb25;fill-rule:nonzero;"/><path d="M506.56,434.305c4.798,-14.309 11.787,-27.782 17.854,-41.572c11.917,2.652 19.741,17.349 13.474,28.315c-3.055,6.355 -10.534,8.199 -17.018,7.522c0.937,1.153 1.873,2.32 2.81,3.502c-2.219,4.294 -4.366,8.617 -6.47,12.983c-4.035,-3.069 -7.623,-6.686 -10.649,-10.75Z" style="fill:#f8ba25;fill-rule:nonzero;"/><path d="M506.401,438.038c5.029,4.453 10.274,8.646 15.692,12.652c-3.775,0.072 -6.614,-2.723 -9.453,-4.784c-2.507,-2.205 -5.793,-4.251 -6.239,-7.868Z" style="fill:#f8ba25;fill-rule:nonzero;"/><path d="M476.905,396.177c1.34,0.115 2.651,0.375 3.977,0.605c1.585,4.971 3.17,9.943 5.245,14.727c-4.553,3.444 -7.003,8.617 -10.13,13.228c-3.156,-5.749 -6.211,-11.571 -8.761,-17.623c3.487,-3.401 6.254,-7.464 9.669,-10.937Z" style="fill:#54728f;fill-rule:nonzero;"/><path d="M494.368,479.306c1.83,-4.02 5.361,-6.816 9.482,-8.242c-2.435,2.434 -4.798,4.957 -7.407,7.205l-2.075,1.037Z" style="fill:#54728f;fill-rule:nonzero;"/><path d="M559.532,658.206c2.146,-2.579 3.991,-5.404 5.634,-8.329c-1.195,3.703 -2.752,7.292 -4.077,10.951c-0.505,-0.878 -1.024,-1.757 -1.557,-2.622Z" style="fill:#54728f;fill-rule:nonzero;"/><path d="M500.379,407.848c3.646,-6.499 9.525,-12.407 17.393,-12.897c-5.548,2.378 -11.456,5.13 -14.323,10.793c-5.303,9.049 -2.306,20.303 3.113,28.56c3.026,4.063 6.614,7.68 10.649,10.75c5.259,3.862 11.023,6.989 17.018,9.539c5.634,2.392 11.441,4.352 17.249,6.254c5.994,1.974 11.844,4.395 17.522,7.176c5.605,2.781 11.052,5.937 16.153,9.583c2.464,1.988 4.683,4.25 7.018,6.412c-0.361,-0.246 -1.081,-0.721 -1.426,-0.966c-9.597,-8.041 -21.155,-13.185 -32.711,-17.724c-2.003,-0.721 -3.976,-1.485 -5.966,-2.219c-3.011,-1.023 -6.038,-2.133 -9.221,-2.421l0.518,-0.244c-6.44,-4.366 -14.309,-6.254 -21.268,-9.756c-5.418,-4.006 -10.663,-8.199 -15.692,-12.652c-2.839,-5.404 -6.383,-10.663 -7.176,-16.845c-0.821,-4.481 0.288,-8.963 1.153,-13.343Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M571.274,419.938c0.636,0.824 0.636,0.824 0,0Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M615.528,458.305c-1.894,-2.833 3.15,-3.18 3.57,-0.578l-0.882,0.882c-0.679,-0.072 -2.024,-0.231 -2.688,-0.303Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M618.955,488.401c0.576,-0.015 1.744,-0.044 2.32,-0.058c0.475,0.446 1.399,1.369 1.859,1.816c-0.677,0.547 -2.032,1.628 -2.709,2.162c-0.086,1.599 -0.244,3.198 -0.461,4.799c-5.072,-0.966 -0.433,-5.549 -1.009,-8.718Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M413.906,541.11l1.388,-0.231l0.81,1.012c-0.506,0.274 -1.547,0.823 -2.067,1.098c-0.029,-0.462 -0.101,-1.403 -0.13,-1.879Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M627.158,578.158c0.028,0.202 0.072,0.592 0.101,0.794l-0.101,-0.794Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M403.17,594.858c1.614,-2.133 2.176,-5.995 5.778,-5.072c-1.369,2.262 -3.473,3.862 -5.778,5.072Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M406.197,597.713c1.153,-2.061 2.234,-4.15 3.213,-6.283c2.046,2.537 4.049,5.303 6.859,7.075c0.058,0.591 0.187,1.773 0.245,2.364c-0.447,0.028 -1.326,0.101 -1.772,0.129c-0.331,-0.893 -1.009,-2.68 -1.355,-3.558c-1.297,1.138 -2.608,2.276 -3.934,3.4c0.692,-1.931 1.47,-3.862 1.859,-5.893c-1.772,0.792 -3.444,1.801 -5.115,2.767Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M582.731,642.672c4.755,10.864 8.472,22.58 7.031,34.598c-2.708,7.319 -5.403,15.274 -11.844,20.23c-7.479,6.341 -19.828,5.173 -25.937,-2.535c4.035,2.363 8.213,5.115 13.127,4.841c7.551,0.332 13.876,-4.913 17.724,-10.98c1.584,-2.781 2.925,-5.72 4.006,-8.733c0.894,-3.257 1.412,-6.614 1.772,-9.972c0.159,-4.437 -0.288,-8.876 -1.498,-13.142c-1.441,-4.784 -2.939,-9.539 -4.38,-14.308Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M554.011,395.457c0.752,0.766 0.752,0.766 0,0Z" style="fill:#495663;fill-rule:nonzero;"/><path d="M428.978,396.091c2.032,2.478 3.617,5.288 5.043,8.156c4.064,-0.36 8.142,-0.793 12.205,-1.239c-2.075,3.415 -5.015,6.167 -8.084,8.689c1.124,3.17 2.565,6.211 4.366,9.064c-0.13,1.023 -0.375,3.084 -0.504,4.121c-3.084,-1.816 -6.268,-3.43 -9.467,-5.015c-0.389,-0.202 -1.196,-0.591 -1.585,-0.793c-3.502,2.262 -6.816,4.827 -10.274,7.147c-0.029,-0.62 -0.086,-1.844 -0.115,-2.464c0.231,-1.585 0.533,-3.141 0.908,-4.683c0.36,0.994 1.081,2.969 1.441,3.963c2.45,-1.96 4.885,-3.934 7.32,-5.922c3.343,1.672 6.715,3.3 10.101,4.914c-1.787,-4.237 -3.271,-8.588 -4.265,-13.07c-5.259,1.931 -10.072,4.928 -13.905,9.035c0.303,-1.066 0.893,-3.199 1.196,-4.265c-2.594,-1.556 -5.159,-3.141 -7.68,-4.813l-0.937,-0.62c3.386,-1.023 6.888,-1.528 10.317,-2.421c2.133,-2.839 2.623,-6.542 3.919,-9.784Z" style="fill:#b1c6e5;fill-rule:nonzero;"/><path d="M582.962,399.966c3.055,-2.349 6.671,1.427 4.582,4.496c-3.099,2.435 -7.335,-1.34 -4.582,-4.496Z" style="fill:#83b7e3;fill-rule:nonzero;"/><path d="M610.153,404.937c7.306,-7.349 19.51,-4.683 27.738,-0.274c-3.126,0.245 -6.268,0.389 -9.395,0.49c-1.325,3.487 -2.521,7.032 -3.717,10.562c-2.161,-2.839 -2.954,-6.369 -4.51,-9.496c-2.954,-1.7 -6.844,-0.692 -10.115,-1.282Z" style="fill:#4c70b2;fill-rule:nonzero;"/><path d="M422.061,478.284c1.989,-2.882 4.337,-5.533 7.536,-7.09c1.729,2.796 3.718,5.418 5.706,8.041c-2.666,-0.072 -5.332,-0.145 -7.997,-0.216c-1.182,2.306 -2.637,4.453 -4.496,6.282c-0.259,-2.348 -0.504,-4.683 -0.749,-7.017Z" style="fill:#4c70b2;fill-rule:nonzero;"/><path d="M446.716,402.285c1.412,4.597 3.055,9.121 4.971,13.531c-2.81,1.974 -5.62,3.963 -8.415,5.951l-0.764,-1.009c-1.801,-2.853 -3.242,-5.894 -4.366,-9.064c3.069,-2.522 6.009,-5.274 8.084,-8.689l0.49,-0.721Z" style="fill:#5a7c9f;fill-rule:nonzero;"/><path d="M643.554,405.729l0.174,-0.014c0.49,5.505 2.132,11.038 1.355,16.571c-0.49,0.375 -1.47,1.11 -1.96,1.47c-3.833,-2.306 -7.782,-4.38 -11.859,-6.211c-0.519,-3.516 -1.384,-6.974 -2.148,-10.433c4.813,-0.375 9.626,-0.807 14.438,-1.383Z" style="fill:#6496c9;fill-rule:nonzero;"/><path d="M442.51,420.758l0.764,1.009c3.991,4.971 7.176,10.562 11.657,15.13c4.251,4.424 8.531,8.862 13.286,12.753c3.747,3.041 7.565,6.009 11.571,8.704c6.297,4.179 12.508,8.53 19.208,12.047c-3.098,1.599 -5.98,-0.764 -8.603,-2.177c-8.56,-5.245 -17.162,-10.576 -24.626,-17.32c-8.949,-7.709 -16.326,-16.917 -23.762,-26.024c0.13,-1.038 0.375,-3.098 0.504,-4.121Z" style="fill:#6496c9;fill-rule:nonzero;"/><path d="M656.653,456.51c0.707,-2.334 1.412,-4.654 2.06,-6.989c0.116,1.297 0.361,3.876 0.49,5.173c-2.104,9.222 -6.095,17.868 -10.361,26.254c-6.412,11.384 -14.193,22.205 -23.689,31.211c5.101,-9.395 12.911,-16.974 18.2,-26.297c2.91,-4.626 5.403,-9.482 7.738,-14.396c2.205,-4.856 3.905,-9.899 5.562,-14.957Z" style="fill:#6496c9;fill-rule:nonzero;"/><path d="M606.319,407.027c4.323,-0.692 8.703,-0.749 13.056,-0.332c1.181,4.237 2.146,8.545 2.493,12.954c-3.559,0 -7.104,-0.014 -10.649,-0.029c-1.441,-4.265 -3.141,-8.444 -4.9,-12.594Z" style="fill:#5c80a6;fill-rule:nonzero;"/><path d="M562.7,408.467c0.708,0.766 0.708,0.766 0,0Z" style="fill:#333335;fill-rule:nonzero;"/><path d="M579.834,413.887c4.293,-2.313 0.303,4.77 0,0Z" style="fill:#333335;fill-rule:nonzero;"/><path d="M624.302,419.333c2.464,-3.689 3.574,-8.012 4.813,-12.219c0.764,3.458 1.628,6.917 2.146,10.433c0.086,2.608 0.073,5.245 0.073,7.882c-2.047,0.014 -4.079,0.043 -6.111,0.072c-0.259,-2.061 -0.604,-4.121 -0.922,-6.167Z" style="fill:#557391;fill-rule:nonzero;"/><path d="M441.199,488.919c4.035,2.19 8.17,4.221 12.436,5.937c0.115,4.12 -2.493,7.349 -6.845,7.262c-3.199,-0.216 -2.306,3.631 0.115,3.934c-3.833,2.276 -6.729,-1.096 -9.453,-3.358c0.821,-2.493 1.052,-5.072 -2.118,-5.85l0.13,-0.173c2.046,-2.48 3.891,-5.116 5.735,-7.752Zm-1.052,11.988c0.793,0.764 0.793,0.764 0,0Zm2.911,2.897c0.764,0.808 0.764,0.808 0,0Z" style="fill:#557391;fill-rule:nonzero;"/><path d="M415.678,408.915c2.522,1.671 5.087,3.257 7.68,4.813c-0.303,1.066 -0.893,3.199 -1.196,4.265l-0.721,0.994c-2.94,-2.695 -4.712,-6.283 -5.764,-10.072Z" style="fill:#3f4a56;fill-rule:nonzero;"/><path d="M422.161,417.992c3.833,-4.107 8.646,-7.104 13.905,-9.035c0.994,4.481 2.479,8.833 4.265,13.07c-3.386,-1.614 -6.758,-3.242 -10.101,-4.914c-2.435,1.989 -4.871,3.963 -7.32,5.922c-0.36,-0.994 -1.081,-2.968 -1.441,-3.963l-0.029,-0.086l0.72,-0.994Z" style="fill:#4a6fb1;fill-rule:nonzero;"/><path d="M559.689,411.264c0.766,0.708 0.766,0.708 0,0Z" style="fill:#323335;fill-rule:nonzero;"/><path d="M578.436,411.279c0.766,0.708 0.766,0.708 0,0Z" style="fill:#3a3b3d;fill-rule:nonzero;"/><path d="M475.998,424.737c3.127,-4.611 5.577,-9.784 10.13,-13.228c3.271,6.946 6.628,13.848 10.015,20.736c-0.721,-0.13 -2.147,-0.375 -2.868,-0.49c-2.723,2.522 -5.548,4.914 -8.487,7.176c-3.141,-4.597 -6.182,-9.28 -8.79,-14.194Z" style="fill:#4b6379;fill-rule:nonzero;"/><path d="M556.794,412.892c0.781,0.679 0.781,0.679 0,0Z" style="fill:#37383a;fill-rule:nonzero;"/><path d="M569.892,412.676c0.68,0.824 0.68,0.824 0,0Z" style="fill:#4d4f52;fill-rule:nonzero;"/><path d="M575.438,412.992c1.96,-0.259 3.502,3.084 2.508,4.683c-2.19,0.49 -3.473,-3.055 -2.508,-4.683Z" style="fill:#202122;fill-rule:nonzero;"/><path d="M588.508,412.733c0.781,0.723 0.781,0.723 0,0Z" style="fill:#494a4d;fill-rule:nonzero;"/><path d="M541.618,413.296c1.513,1.528 2.68,3.66 5.043,3.992c-0.475,0.346 -1.412,1.037 -1.873,1.369c2.161,3.631 4.366,7.234 6.469,10.908c-0.764,0.346 -2.276,1.066 -3.04,1.427l-0.244,-0.634c-0.995,-6.225 -7.71,-10.289 -6.355,-17.061Z" style="fill:#4d4e50;fill-rule:nonzero;"/><path d="M585.426,414.289c4.769,0.145 -2.154,4.365 0,0Z" style="fill:#303133;fill-rule:nonzero;"/><path d="M494.37,420.961c-0.317,-2.233 -1.052,-6.96 3.141,-5.706c-0.648,4.698 -0.014,9.482 2.075,13.761c1.7,3.329 3.098,7.435 6.816,9.021c0.447,3.617 3.732,5.663 6.239,7.868c2.839,2.061 5.677,4.856 9.453,4.784c0.793,2.162 2.968,3.026 4.885,3.948c5.274,2.406 10.692,4.554 16.384,5.807l-0.519,0.246c-2.867,1.355 -5.777,2.636 -8.674,3.905c-10.519,-0.951 -21.053,1.527 -30.318,6.469c-4.121,1.428 -7.652,4.222 -9.482,8.242c-1.023,1.744 -1.21,3.818 -1.585,5.764c-1.167,-1.052 -2.291,-2.133 -3.358,-3.257c0.36,-2.449 2.234,-4.351 3.43,-6.455c-10.361,-6.254 -19.799,-13.819 -29.54,-20.952c-0.591,-0.403 -1.772,-1.196 -2.363,-1.585c1.599,-0.649 3.199,-1.283 4.813,-1.917c7.464,6.744 16.067,12.076 24.626,17.32c2.623,1.412 5.505,3.776 8.603,2.176c5,-3.429 10.548,-5.85 15.995,-8.43c-2.954,-5.259 -6.009,-10.475 -9.467,-15.432c-4.971,-7.882 -9.193,-16.442 -11.153,-25.577Z" style="fill:#272e33;fill-rule:nonzero;"/><path d="M571.434,415.429c4.307,-2.298 0.275,4.77 0,0Z" style="fill:#38393c;fill-rule:nonzero;"/><path d="M599.936,422.578c4.192,-2.457 0.491,4.77 0,0Z" style="fill:#38393c;fill-rule:nonzero;"/><path d="M563.998,416.984c0.823,0.766 0.823,0.766 0,0Z" style="fill:#73757a;fill-rule:nonzero;"/><path d="M582.76,416.971c0.823,0.752 0.823,0.752 0,0Z" style="fill:#707276;fill-rule:nonzero;"/><path d="M595.916,416.984c0.741,0.726 0.741,0.726 0,0Z" style="fill:#313234;fill-rule:nonzero;"/><path d="M601.52,416.971c0.737,0.766 0.737,0.766 0,0Z" style="fill:#404144;fill-rule:nonzero;"/><path d="M561.39,417.848c0.98,2.565 3.213,5.591 0,7.579c1.441,1.571 2.897,3.17 4.338,4.77c1.397,-3.689 2.781,-3.819 4.149,-0.403c-0.821,0.144 -2.478,0.418 -3.299,0.548c0.778,2.176 1.426,4.453 -0.216,6.427c4.554,-2.824 5.908,3.674 8.848,5.865c10.561,10.13 21.398,20.419 28.329,33.488c0.216,0.778 0.648,2.349 0.85,3.142c-0.648,-0.477 -1.96,-1.441 -2.608,-1.917c-6.758,-12.176 -16.96,-21.918 -27.248,-31.111c-2.522,-2.147 -4.611,-4.741 -6.023,-7.738c-0.462,0.259 -1.355,0.793 -1.816,1.052c-2.003,-1.974 -4.035,-3.934 -6.111,-5.807c1.758,2.868 3.632,5.677 5.461,8.531c0.462,-0.245 1.399,-0.735 1.859,-0.98c8.661,8.66 17.739,16.975 25.447,26.528c2.68,3.286 4.655,7.306 8.43,9.525c-0.072,0.922 -0.202,2.767 -0.274,3.688c-8.127,-7.853 -17.118,-14.798 -26.989,-20.318c-4.567,-2.406 -8.142,-6.167 -11.903,-9.625c-5.274,-4.381 -10.26,-9.194 -13.776,-15.145l-0.158,-0.245c2.291,1.124 4.366,2.637 6.24,4.395c10.071,7.724 18.747,17.205 29.438,24.122c-3.516,-4.828 -8.199,-8.589 -12.002,-13.157c-5.232,-4.453 -11.183,-8.531 -14.209,-14.929c-0.044,-0.562 -0.158,-1.7 -0.216,-2.262c-0.619,-0.288 -1.873,-0.864 -2.508,-1.153c-0.432,-0.908 -1.296,-2.709 -1.729,-3.617c1.816,0.029 3.589,-0.85 4.453,-2.493c1.643,1.844 3.126,4.15 5.75,4.654c-1.845,-3.602 -5.015,-6.456 -6.311,-10.332l-0.216,-0.591c0.922,0.562 2.737,1.7 3.646,2.262c0.101,-1.196 0.288,-3.559 0.375,-4.755Zm2.911,14.957c0.792,0.807 0.792,0.807 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M568.35,422.79c0.81,0.809 0.81,0.809 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M594.488,427.042c0.752,0.867 0.752,0.867 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M571.318,430.919c0.677,-0.893 2.032,-2.709 2.709,-3.617c0.231,0.98 0.692,2.94 0.922,3.919c1.816,-1.412 6.888,-5.346 5.649,0.778c-0.907,-0.13 -2.723,-0.403 -3.631,-0.533c-0.043,0.548 -0.158,1.657 -0.215,2.205c1.252,-0.303 3.775,-0.922 5.043,-1.239c-0.576,1.081 -1.7,3.213 -2.276,4.28l1.542,0.245l0.301,-2.738c0.591,0.331 1.788,1.009 2.392,1.34c0.534,1.974 -1.469,3.055 -2.506,4.366c2.622,-1.513 4.856,-4.899 8.228,-4.208l0.303,0.807c0.274,0.461 0.821,1.369 1.094,1.816c0.519,0.533 1.527,1.599 2.047,2.133c0.043,0.058 0.158,0.173 0.215,0.231l0.13,-0.144l1.672,-1.801c2.607,1.974 4.611,4.597 6.499,7.234c-0.606,-1.527 -1.197,-3.055 -1.788,-4.582c1.399,-1.355 2.652,-2.868 3.2,-4.77c-1.729,1.138 -3.4,2.349 -5.058,3.574l-0.389,-2.234l1.124,-1.167l0.547,-0.332l0.448,-0.303c1.21,-0.259 3.646,-0.778 4.87,-1.038l-0.461,2.968c2.003,-2.781 3.617,-2.723 4.856,0.187c-3.17,-0.303 -6.067,2.378 -1.052,3.098c-3.055,2.968 -5.259,7.118 -9.713,8.242c4.597,-0.519 7.523,-4.597 11.615,-6.34l0.936,2.81c0.015,0.058 0.044,0.144 0.044,0.202c0.057,0.562 0.143,1.715 0.202,2.277c-0.995,1.599 -2.003,3.199 -3.012,4.77l-1.757,-0.288c0.821,0.634 2.478,1.931 3.314,2.565c0.057,0.173 0.173,0.504 0.231,0.663c-0.995,3.386 -2.234,6.744 -2.537,10.289c4.54,2.666 2.335,-6.024 7.003,-4.18c-0.029,0.908 -0.072,2.695 -0.101,3.603l-0.793,0.028l0.015,0.764l0.778,-0.015c-1.34,5.375 -2.623,10.779 -4.294,16.083l-0.36,1.181c-5.246,-12.637 -13.79,-23.689 -23.545,-33.171c-5.707,-5.173 -12.149,-9.943 -15.607,-17.032c1.773,-0.504 3.257,-1.441 4.439,-2.81l-3.271,-0.115Zm2.84,3.43c0.806,0.735 0.806,0.735 0,0Zm2.579,2.68c4.769,0.043 -2.061,4.409 0,0Zm10.317,1.499c0.764,0.749 0.764,0.749 0,0Zm-2.767,2.911c0.778,0.793 0.778,0.793 0,0Zm4.553,-0.202c-0.446,1.844 1.6,4.755 3.488,4.87c1.311,-2.017 -1.067,-5.793 -3.488,-4.87Zm14.28,0.303c0.778,0.85 0.778,0.85 0,0Zm-21.802,1.542c0.72,0.749 0.72,0.749 0,0Zm12.94,-0.346c4.193,-2.421 0.461,4.784 0,0Zm-8.444,1.47c4.265,-2.306 0.331,4.755 0,0Zm11.354,1.628c0.85,0.692 0.85,0.692 0,0Zm-3.083,1.455c4.769,0.187 -2.177,4.337 0,0Zm13.315,0.158c0.792,0.778 0.792,0.778 0,0Zm-17.292,2.536c4.222,-2.392 0.418,4.755 0,0Zm14.381,0.101c0.778,0.764 0.778,0.764 0,0Zm-8.632,1.484c0.707,0.764 0.707,0.764 0,0Zm5.418,-0.086c4.727,0.245 -2.205,4.352 0,0Zm-1.023,5.966c0.707,0.749 0.707,0.749 0,0Zm2.739,2.81c0.72,0.734 0.72,0.734 0,0Zm-3.2,1.555c4.769,0.13 -2.148,4.352 0,0Zm5.779,7.969c2.781,2.695 2.895,-3.574 0.013,-0.951c0,0.246 0,0.707 -0.013,0.951Zm3.285,4.9c0.721,0.764 0.721,0.764 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M569.819,431.091c0.825,0.795 0.825,0.795 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M588.235,431.624c0.766,0.853 0.766,0.853 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M585.253,432.936l1.272,-0.101c2.644,2.486 -2.805,3.122 -1.272,0.101Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M539.501,434.362c0.708,0.766 0.708,0.766 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M536.692,437.216c0.723,0.752 0.723,0.752 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M608.582,439.695c0.796,0.824 0.796,0.824 0,0Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M543.665,472.879c7.018,-0.072 14.064,0.879 20.548,3.704c-3.126,0.187 -6.253,0.475 -9.351,0.806c-2.377,0.275 -4.727,0.663 -7.046,1.197c-1.384,-1.902 -2.781,-3.804 -4.15,-5.707Z" style="fill:#7d8187;fill-rule:nonzero;"/><path d="M574.186,418.54c0.823,0.781 0.823,0.781 0,0Z" style="fill:#6f7176;fill-rule:nonzero;"/><path d="M579.606,418.613c4.784,0.217 -2.226,4.337 0,0Z" style="fill:#3d3f42;fill-rule:nonzero;"/><path d="M592.875,418.484c0.781,0.737 0.781,0.737 0,0Z" style="fill:#505256;fill-rule:nonzero;"/><path d="M631.262,417.547c4.079,1.83 8.027,3.905 11.86,6.211c0.489,-0.36 1.469,-1.095 1.959,-1.47l0.648,-0.475c0.073,3.775 0.231,7.536 0.404,11.297c0.044,2.623 0.173,5.231 0.576,7.839c-0.461,-0.447 -1.369,-1.311 -1.83,-1.758c-0.375,-0.836 -1.124,-2.507 -1.484,-3.343c-3.906,-2.392 -7.954,-4.525 -12.033,-6.571c0,-0.965 -0.013,-2.896 -0.028,-3.847c0,-2.637 0.015,-5.274 -0.073,-7.882Z" style="fill:#5f89b4;fill-rule:nonzero;"/><path d="M420.678,426.221c3.458,-2.32 6.773,-4.885 10.274,-7.147c0.389,0.202 1.196,0.591 1.585,0.793c2.695,5.62 5.692,11.211 10.159,15.663c-3.415,2.839 -6.758,5.75 -10.015,8.761c-4.582,-5.764 -9.554,-11.297 -13.07,-17.796c0.245,-0.692 0.721,-2.061 0.951,-2.738c0.029,0.62 0.086,1.845 0.115,2.464Z" style="fill:#5b7da1;fill-rule:nonzero;"/><path d="M565.526,419.766c4.336,-2.226 0.231,4.77 0,0Z" style="fill:#4d4e51;fill-rule:nonzero;"/><path d="M584.201,419.81c4.235,-2.414 0.42,4.785 0,0Z" style="fill:#414245;fill-rule:nonzero;"/><path d="M598.612,419.837c0.809,0.665 0.809,0.665 0,0Z" style="fill:#3e4043;fill-rule:nonzero;"/><path d="M602.904,419.795c4.206,-2.443 0.448,4.77 0,0Z" style="fill:#232426;fill-rule:nonzero;"/><path d="M611.22,419.622c3.545,0.014 7.09,0.029 10.649,0.029c0.187,2.003 0.389,4.006 0.606,6.023c0.028,0.245 0.072,0.735 0.101,0.98c0.23,3.199 0.375,6.427 0.461,9.655c-2.897,-0.49 -6.01,-1.081 -8.602,0.677c-0.938,-5.807 -1.99,-11.6 -3.214,-17.364Z" style="fill:#55728f;fill-rule:nonzero;"/><path d="M634.001,444.088c3.775,1.585 8.415,2.983 9.496,7.522c-0.015,0.908 -0.044,2.738 -0.073,3.646c-0.389,1.772 -0.734,3.545 -1.067,5.318c-4.87,-1.96 -11.037,-4.107 -11.512,-10.275c1.067,-2.075 2.104,-4.15 3.156,-6.211Z" style="fill:#55728f;fill-rule:nonzero;"/><path d="M654.852,419.808c0.823,0.867 0.823,0.867 0,0Z" style="fill:#6c9abf;fill-rule:nonzero;"/><path d="M432.538,419.867c3.199,1.585 6.383,3.199 9.467,5.015c7.435,9.107 14.813,18.315 23.762,26.024c-1.614,0.634 -3.213,1.268 -4.813,1.917c-7.32,-4.352 -12.335,-11.369 -18.257,-17.292c-4.467,-4.453 -7.464,-10.044 -10.159,-15.663Z" style="fill:#415364;fill-rule:nonzero;"/><path d="M472.02,442.448c4.337,3.602 8.631,7.248 12.911,10.937c-1.729,1.643 -3.43,3.314 -5.144,4.971c-4.006,-2.695 -7.825,-5.663 -11.571,-8.703c1.268,-2.406 2.536,-4.798 3.804,-7.205Z" style="fill:#415364;fill-rule:nonzero;"/><path d="M539.01,420.63c3.156,3.069 6.038,6.427 8.964,9.727l0.244,0.634c0.764,-0.36 2.276,-1.081 3.04,-1.427l0.677,-0.317c0.663,1.686 1.83,2.839 3.488,3.473c0.634,0.288 1.887,0.864 2.508,1.153c0.057,0.562 0.173,1.7 0.215,2.262c-1.052,1.34 -2.132,2.651 -3.227,3.963c-1.873,-1.758 -3.949,-3.271 -6.24,-4.395l-0.015,0c0.044,0.058 0.13,0.187 0.174,0.245c3.516,5.951 8.501,10.764 13.776,15.145c0.114,0.922 0.36,2.752 0.475,3.675c-1.485,-0.677 -2.941,-1.369 -4.382,-2.075l-0.432,-0.72c-2.075,-4.222 -5.807,-7.147 -9.381,-10.029c-0.446,-0.014 -1.34,-0.014 -1.786,-0.029c-1.816,-2.262 -3.949,-4.28 -6.5,-5.692c1.009,1.801 2.104,3.559 3.229,5.303c-2.278,-0.864 -4.611,-1.772 -7.09,-1.225c-2.868,0.259 -4.842,-2.435 -7.133,-3.703c2.608,-2.219 5.318,-4.309 8.099,-6.326c-0.692,-0.216 -2.076,-0.663 -2.781,-0.893l-1.038,-0.346c1.614,-1.441 3.344,-2.767 5.13,-3.991c0.202,0.879 0.62,2.651 0.821,3.545c1.83,1.081 3.66,2.162 5.577,3.113c-1.946,-3.819 -6.585,-6.369 -6.412,-11.067Zm6.311,12.133c0.764,0.735 0.764,0.735 0,0Zm-8.472,1.686c0.749,0.721 0.749,0.721 0,0Zm2.651,-0.086c0.707,0.764 0.707,0.764 0,0Zm-2.81,2.853c0.721,0.749 0.721,0.749 0,0Z" style="fill:#4a4b4d;fill-rule:nonzero;"/><path d="M576.85,421.35c0.796,0.781 0.796,0.781 0,0Z" style="fill:#6e7175;fill-rule:nonzero;"/><path d="M590.065,421.237c0.737,0.824 0.737,0.824 0,0Z" style="fill:#616468;fill-rule:nonzero;"/><path d="M608.724,421.379c0.766,0.766 0.766,0.766 0,0Z" style="fill:#525356;fill-rule:nonzero;"/><path d="M577.486,426.395c0.259,-2.666 3.126,-2.925 4.87,-4.136c1.47,1.513 2.853,3.098 4.077,4.813l-2.809,0.014c0.143,-1.888 -0.62,-3.17 -2.32,-3.819c-0.951,0.778 -2.853,2.334 -3.818,3.127Z" style="fill:#797678;fill-rule:nonzero;"/><path d="M573.998,423.843c1.772,-0.058 4.063,2.464 3.429,4.323c-2.205,1.239 -3.905,-2.551 -3.429,-4.323Z" style="fill:#797678;fill-rule:nonzero;"/><path d="M445.968,559.006c1.772,1.915 3.617,3.76 5.562,5.504c4.309,3.66 7.81,8.112 10.937,12.795c-0.533,0.246 -1.585,0.707 -2.118,0.938c1.816,1.614 3.545,3.343 5.072,5.245c-0.548,1.584 -1.081,3.17 -1.614,4.769c1.657,-0.446 3.329,-0.894 5,-1.325c1.066,2.247 1.772,4.654 1.715,7.191c1.758,1.355 3.53,2.695 5.332,4.035c1.095,2.622 2.205,5.23 3.329,7.839c1.787,3.949 3.3,8.07 3.631,12.435c-0.836,0.116 -2.522,0.345 -3.372,0.475c0.086,1.816 0.159,3.646 0.245,5.491c0.519,1.944 1.023,3.89 1.542,5.864c-1.167,0.058 -3.516,0.159 -4.698,0.202c-0.187,0.995 -0.591,2.968 -0.793,3.963c1.484,-0.894 2.968,-1.786 4.467,-2.68c1.974,4.035 4.899,8.617 10,8.602c5.908,0.808 8.747,-5.028 12.637,-8.213c-0.158,1.671 -0.331,3.358 -0.504,5.028c3.228,3.719 6.672,7.422 11.254,9.454c-0.548,0 -1.657,-0.015 -2.205,-0.015c-2.695,-0.044 -5.418,-0.158 -8.069,0.404c-3.026,1.296 -5.072,4.035 -7.45,6.21c0.303,1.254 0.605,2.508 0.908,3.775c-1.009,-0.562 -2.032,-1.124 -3.04,-1.671l-1.715,-0.923c1.239,-1.671 2.421,-3.385 3.545,-5.144c-2.046,0.923 -3.631,2.551 -5.317,3.992c-0.576,-2.522 0.793,-4.598 2.061,-6.601c-1.614,1.067 -2.911,2.81 -4.87,3.3c-0.389,-0.835 -1.196,-2.522 -1.599,-3.358c-1.859,1.845 -4.64,5.087 -5.577,0.62c0.807,-0.389 2.406,-1.167 3.199,-1.571c-1.369,-1.441 -2.81,-2.81 -4.236,-4.193c-1.83,1.917 -3.934,3.53 -6.009,5.159c-0.216,-0.692 -0.648,-2.061 -0.85,-2.752c-0.663,-0.246 -1.989,-0.707 -2.652,-0.938l-0.403,-2.434c0.836,0.547 2.493,1.614 3.329,2.146c-0.259,-1.166 -0.519,-2.319 -0.764,-3.457c0.922,-1.009 1.844,-2.004 2.767,-3.012c-1.888,-1.009 -3.804,-4.856 -5.879,-1.786c-0.519,-1.081 -0.994,-2.234 -1.455,-3.315l-0.029,-0.086l-0.043,-0.057c-0.504,-0.562 -1.513,-1.701 -2.003,-2.278l1.11,-1.353c0.389,-0.505 1.196,-1.514 1.599,-2.017l0.058,-0.044l-0.259,-0.244c-0.634,-0.62 -1.902,-1.889 -2.536,-2.522l-0.288,-0.375l-0.346,-0.303c-0.533,-0.619 -1.599,-1.845 -2.147,-2.449l-0.475,-0.793c0.648,-0.562 1.974,-1.685 2.637,-2.249c0.086,0.073 0.245,0.187 0.332,0.26c0.591,0.591 1.801,1.772 2.406,2.377l0.519,0.432c1.859,0.116 3.732,0.145 5.605,0.202c0.994,-1.325 2.017,-2.607 3.055,-3.905c-2.19,-1.498 -4.424,-2.882 -6.859,-3.905l1.038,-1.153c-0.014,-0.029 -0.058,-0.086 -0.072,-0.116c-0.562,-0.734 -1.7,-2.205 -2.277,-2.939l-0.115,-0.231c-2.306,-6.153 -7.435,-10.418 -11.067,-15.692c-0.692,-0.231 -2.075,-0.663 -2.781,-0.879c0.807,-0.85 1.599,-1.7 2.406,-2.535c-3.199,-4.352 -6.902,-8.316 -10.995,-11.846c0.389,-1.267 0.778,-2.521 1.167,-3.775c-2.334,-2.68 -4.755,-5.303 -7.032,-8.026c1.542,-0.778 3.069,-1.571 4.582,-2.392c0.375,-0.793 1.11,-2.364 1.47,-3.156Zm9.928,16.412c4.294,-2.276 0.317,4.769 0,0Zm11.614,26.01c0.692,0.777 0.692,0.777 0,0Zm5.836,0.086c0.721,0.764 0.721,0.764 0,0Zm-2.911,2.853c0.721,0.764 0.721,0.764 0,0Zm5.692,0.072c0.778,0.707 0.778,0.707 0,0Zm-2.795,2.767c0.764,0.692 0.764,0.692 0,0Zm2.839,2.897c0.692,0.778 0.692,0.778 0,0Zm2.925,2.882c0.735,0.764 0.735,0.764 0,0Zm-8.732,8.674c0.735,0.793 0.735,0.793 0,0Zm5.807,0.058c0.749,0.705 0.749,0.705 0,0Zm-2.839,2.781c0.749,0.749 0.749,0.749 0,0Zm-2.968,2.867c0.821,0.734 0.821,0.734 0,0Zm5.735,0.072c0.793,0.692 0.793,0.692 0,0Zm-2.911,2.897c0.821,0.749 0.821,0.749 0,0Zm5.793,5.691c0.821,0.764 0.821,0.764 0,0Zm-2.882,3.027c0.821,0.764 0.821,0.764 0,0Zm5.778,-0.13c0.778,0.808 0.778,0.808 0,0Zm17.277,0.072c0.821,0.764 0.821,0.764 0,0Zm-14.424,2.882c0.807,0.778 0.807,0.778 0,0Zm5.778,-0.116c0.821,0.764 0.821,0.764 0,0Zm5.85,-0.028c0.793,0.806 0.793,0.806 0,0Zm5.706,0.072c0.821,0.749 0.821,0.749 0,0Zm-14.295,3.04c0.807,0.778 0.807,0.778 0,0Zm5.75,-0.116c0.793,0.808 0.793,0.808 0,0Zm5.663,-0.013c0.764,0.821 0.764,0.821 0,0Zm-2.868,2.925c0.836,0.749 0.836,0.749 0,0Z" style="fill:#797678;fill-rule:nonzero;"/><path d="M597.04,671.706c7.752,12.119 9.539,29.713 -1.081,40.779c1.6,1.672 3.2,3.343 4.813,5.03c3.89,-6.01 7.594,-12.58 7.435,-19.973c0.75,-8.545 -2.478,-16.571 -5.432,-24.381c4.107,8.011 7.22,16.874 6.412,26.009c-0.216,8.142 -4.496,15.391 -9.452,21.572c-3.128,-8.156 -11.6,-13.761 -20.333,-13.214c-11.615,0.461 -21.485,11.643 -20.505,23.215c0.505,7.42 5.908,13.732 12.393,16.946c7.622,3.227 16.485,2.031 23.949,-1.11c20.72,-8.573 33.791,-32.84 28.546,-54.814c-1.067,-7.205 -5.318,-13.287 -7.508,-20.103c4.611,8.589 9.063,17.551 9.654,27.481c2.421,24.208 -16.498,48.661 -40.678,51.889c-11.139,1.744 -22.306,-4.15 -28.79,-13.012c-6.513,-8.934 -5.16,-22.335 2.493,-30.16c1.023,-0.677 2.06,-1.327 3.126,-1.931c-2.867,3.703 -6.225,7.305 -7.363,11.989c-1.801,6.297 -0.677,13.227 2.867,18.732c0,-3.877 -0.446,-7.752 0.158,-11.585c2.104,-10.534 12.176,-19.41 23.185,-18.646c5.289,-0.202 9.871,2.693 14.554,4.711c-2.752,-5.59 -8.718,-7.94 -14.236,-9.856c0.23,-0.215 0.705,-0.619 0.951,-0.821c5.39,1.311 9.755,4.698 13.948,8.142c0.332,0.015 0.98,0.029 1.312,0.029c8.011,-10.88 5.144,-25.607 -0.418,-36.918Z" style="fill:#797678;fill-rule:nonzero;"/><path d="M611.276,673.175c5.966,8.358 10.203,18.359 9.944,28.777c1.584,22.306 -17.711,44.526 -40.276,44.757c-10.073,0.705 -20.303,-8.228 -19.136,-18.662c0.764,-11.253 12.955,-20.533 23.949,-16.456c6.715,2.278 10.662,9.252 10.231,16.168c9.583,-7.565 17.018,-18.949 16.658,-31.528c0.606,-7.479 -2.249,-14.497 -3.804,-21.643l0.202,0.533c4.467,10.807 6.671,23.301 2.493,34.554c-3.574,8.934 -9.885,16.975 -18.374,21.717c1.946,-6.384 0.677,-14.065 -5.634,-17.48c-10.115,-6.24 -23.588,2.911 -24.164,14.078c-1.039,10.231 9.452,18.243 19.121,17.133c22.739,-1.816 40.405,-25.218 37.638,-47.537c-0.648,-8.329 -4.237,-15.982 -8.213,-23.185l-0.635,-1.226Z" style="fill:#797678;fill-rule:nonzero;"/><path d="M587.067,422.95c0.781,0.824 0.781,0.824 0,0Z" style="fill:#717479;fill-rule:nonzero;"/><path d="M540.612,436.222c2.551,1.412 4.683,3.43 6.499,5.692c0.448,0.014 1.34,0.014 1.788,0.029c3.573,2.882 7.305,5.807 9.381,10.029c-2.276,-0.807 -4.54,-1.643 -6.801,-2.435c0.116,-0.519 0.331,-1.542 0.446,-2.061c-3.574,1.917 -7.839,-1.686 -8.142,-5.36l-0.116,3.127c-2.521,-1.34 -4.885,-2.94 -6.916,-4.943c2.478,-0.548 4.812,0.36 7.09,1.225c-1.125,-1.744 -2.219,-3.502 -3.229,-5.303Z" style="fill:#717479;fill-rule:nonzero;"/><path d="M605.873,422.95c0.766,0.737 0.766,0.737 0,0Z" style="fill:#47484a;fill-rule:nonzero;"/><path d="M663.583,422.778c1.959,1.182 2.407,3.141 1.225,5.115c1.21,1.47 2.434,2.954 3.675,4.438c3.257,-0.62 6.541,-1.095 9.856,-1.47c-1.197,2.306 -2.407,4.611 -3.545,6.946l-1.773,-0.692c-4.035,-1.542 -7.969,-3.329 -11.83,-5.231c-2.017,1.369 -4.15,2.651 -6.557,3.199c0.764,-3.314 3.113,-5.505 6.686,-4.928c0.173,-2.623 0.418,-5.332 2.263,-7.378Z" style="fill:#9cb7dd;fill-rule:nonzero;"/><path d="M565.222,424.463c4.815,0.231 -2.283,4.322 0,0Z" style="fill:#676a6f;fill-rule:nonzero;"/><path d="M588.482,426.869c-0.231,-2.205 3.141,-3.473 4.942,-2.882c-0.015,1.931 -3.185,3.905 -4.942,2.882Z" style="fill:#46474a;fill-rule:nonzero;"/><path d="M553.694,429.103c-1.412,-1.974 4.077,-5.26 4.452,-2.493c-0.863,1.643 -2.636,2.522 -4.452,2.493Z" style="fill:#414345;fill-rule:nonzero;"/><path d="M488.62,514.379c3.3,0.029 6.686,-0.475 9.957,0.332c1.009,2.003 -2.19,2.449 -3.156,3.675c0.418,0.475 0.836,0.966 1.254,1.441c-0.49,2.19 -0.98,4.366 -1.412,6.57c-0.85,3.213 -1.758,6.412 -2.608,9.612c-1.917,0.562 -3.833,1.124 -5.735,1.7c-0.043,2.146 -0.086,4.294 -0.115,6.455c-1.614,-0.028 -3.242,-0.028 -4.856,-0.043c-1.888,-3.126 -4.222,-5.937 -6.859,-8.444c0.231,-1.153 0.461,-2.291 0.721,-3.429c2.795,-1.426 5.231,-3.617 5.159,-7.018c-3.055,0.202 -6.124,0.36 -9.165,0.562c1.225,-0.648 2.45,-1.311 3.674,-1.974c3.574,-1.355 7.219,-2.537 10.851,-3.732c0.749,-1.441 1.499,-2.882 2.19,-4.352l0.101,-1.355Zm-3.314,13.228c0.231,0.98 0.692,2.926 0.922,3.891c2.306,0.734 2.81,-1.527 3.631,-3.113c-1.138,-0.187 -3.415,-0.576 -4.554,-0.778Z" style="fill:#414345;fill-rule:nonzero;"/><path d="M569.934,425.676c4.278,-2.371 0.332,4.813 0,0Z" style="fill:#6c6f73;fill-rule:nonzero;"/><path d="M602.948,425.659c0.823,0.766 0.823,0.766 0,0Z" style="fill:#76787d;fill-rule:nonzero;"/><path d="M607.371,425.603c4.366,-2.125 0.073,4.813 0,0Z" style="fill:#29292b;fill-rule:nonzero;"/><path d="M625.226,425.5c2.032,-0.029 4.063,-0.058 6.109,-0.072c0.015,0.951 0.029,2.882 0.029,3.848c0.072,4.856 0.259,9.727 0.086,14.597c-1.931,0.058 -3.848,0.115 -5.75,0.173c-0.029,-6.182 0.086,-12.378 -0.475,-18.545Z" style="fill:#4e667d;fill-rule:nonzero;"/><path d="M598.394,430.113c0.793,-1.311 1.614,-2.594 2.464,-3.876c0.057,0.735 0.158,2.219 0.216,2.954c2.06,0.476 3.011,1.744 2.853,3.79c0.116,0.533 0.345,1.614 0.461,2.161c-1.225,0.259 -3.66,0.778 -4.87,1.038c-0.259,0.158 -0.749,0.475 -0.995,0.634l-1.124,1.167c-0.331,-0.346 -1.008,-1.023 -1.355,-1.355c-0.274,0.533 -0.821,1.599 -1.109,2.133l-1.672,1.801l-0.345,-0.086c-0.519,-0.533 -1.527,-1.599 -2.047,-2.133c-0.274,-0.447 -0.821,-1.355 -1.094,-1.816l-0.303,-0.807l-0.116,-1.801c0.591,-0.461 1.786,-1.369 2.392,-1.816c-0.375,-1.182 -1.124,-3.53 -1.513,-4.712c1.988,0.98 3.227,3.141 5.447,3.703c0.677,-0.245 2.032,-0.735 2.709,-0.98Zm-5.375,4.265c0.778,0.706 0.778,0.706 0,0Z" style="fill:#525458;fill-rule:nonzero;"/><path d="M585.699,428.255c4.336,-2.197 0.159,4.842 0,0Z" style="fill:#777b80;fill-rule:nonzero;"/><path d="M604.402,428.356c4.395,-2.125 0.145,4.77 0,0Z" style="fill:#4c4e51;fill-rule:nonzero;"/><path d="M609.909,428.728c4.769,0.159 -2.14,4.38 0,0Z" style="fill:#45464a;fill-rule:nonzero;"/><path d="M585.815,444.205c4.278,-2.313 0.332,4.77 0,0Z" style="fill:#45464a;fill-rule:nonzero;"/><path d="M460.637,429.896c3.631,4.337 7.407,8.545 11.384,12.551c-1.268,2.406 -2.536,4.798 -3.804,7.205c-4.755,-3.891 -9.035,-8.329 -13.286,-12.753c1.902,-2.334 3.804,-4.669 5.706,-7.003Z" style="fill:#4a6176;fill-rule:nonzero;"/><path d="M595.687,431.097c0.433,-0.434 1.271,-1.286 1.691,-1.72l1.027,0.737c-0.68,0.246 -2.039,0.737 -2.717,0.983Z" style="fill:#686b70;fill-rule:nonzero;"/><path d="M631.364,429.276c4.079,2.046 8.127,4.179 12.033,6.571c0.36,0.836 1.109,2.507 1.484,3.343c0.461,0.447 1.369,1.311 1.83,1.758l0.389,0.36c-1.801,2.565 -2.148,5.721 -2.767,8.718l-0.519,1.009l-0.316,0.576c-1.081,-4.539 -5.722,-5.937 -9.496,-7.522c-1.052,2.061 -2.089,4.136 -3.156,6.211c0.244,-2.147 0.446,-4.28 0.604,-6.427c0.174,-4.87 -0.013,-9.741 -0.086,-14.597Z" style="fill:#5b7da0;fill-rule:nonzero;"/><path d="M607.17,431.438c0.838,0.781 0.838,0.781 0,0Z" style="fill:#7b7e84;fill-rule:nonzero;"/><path d="M661.193,431.883c3.861,1.902 7.795,3.689 11.83,5.231c0.576,1.268 1.182,2.55 1.801,3.833c1.672,2.551 3.372,5.087 5.015,7.666c-4.178,-1.282 -8.343,-2.911 -12.767,-2.954c-2.723,2.925 -5.375,5.922 -7.868,9.035c-0.129,-1.297 -0.375,-3.876 -0.489,-5.173c0.057,-2.695 1.353,-5.62 0.288,-8.156c-2.566,-2.017 -5.837,-2.723 -8.877,-3.646c-0.143,4.842 -0.259,9.712 -1.383,14.453c-0.347,0.692 -1.009,2.075 -1.34,2.767c-0.995,-1.499 -2.19,-2.796 -3.589,-3.905l0.519,-1.009c1.412,-1.297 2.91,-2.507 4.351,-3.761c0.707,-1.772 -0.028,-3.919 -1.584,-4.957l-0.389,-0.36c-0.404,-2.608 -0.534,-5.216 -0.576,-7.839c0.966,0.706 2.897,2.104 3.862,2.81c1.153,-0.202 3.486,-0.634 4.639,-0.836c2.407,-0.548 4.54,-1.83 6.557,-3.199Zm-2.551,6.311c4.294,3.069 1.384,8.545 1.658,12.81c2.522,-2.349 4.74,-5 7.262,-7.349c2.926,0.576 5.735,1.585 8.632,2.363c-1.513,-8.084 -11.283,-7.522 -17.551,-7.825Z" style="fill:#b5c9e7;fill-rule:nonzero;"/><path d="M523.679,432.072c3.847,4.928 8.804,8.804 14.136,12.018c-1.254,3.487 -2.449,6.974 -3.588,10.505c-5.994,-2.551 -11.758,-5.677 -17.018,-9.539c2.104,-4.366 4.251,-8.689 6.47,-12.983Z" style="fill:#f6a926;fill-rule:nonzero;"/><path d="M522.094,450.689c6.96,3.502 14.828,5.389 21.268,9.755c-5.691,-1.253 -11.11,-3.4 -16.383,-5.807c-1.917,-0.922 -4.092,-1.787 -4.885,-3.948Z" style="fill:#f6a926;fill-rule:nonzero;"/><path d="M529.43,432.85c0.809,0.795 0.809,0.795 0,0Z" style="fill:#797c82;fill-rule:nonzero;"/><path d="M564.3,432.806c0.796,0.809 0.796,0.809 0,0Z" style="fill:#030303;fill-rule:nonzero;"/><path d="M603.927,432.979c0.521,0.231 1.547,0.708 2.053,0.94l-0.462,1.185l-1.128,0.043c-0.116,-0.549 -0.348,-1.633 -0.464,-2.168Z" style="fill:#6e7277;fill-rule:nonzero;"/><path d="M536.849,434.448c0.752,0.723 0.752,0.723 0,0Z" style="fill:#0b0b0c;fill-rule:nonzero;"/><path d="M560.583,433.741c2.075,1.873 4.107,3.833 6.109,5.807c0.461,-0.259 1.355,-0.793 1.816,-1.052c1.412,2.997 3.502,5.591 6.023,7.738c10.289,9.193 20.491,18.934 27.25,31.111c-3.776,-2.219 -5.75,-6.24 -8.43,-9.525c-7.71,-9.553 -16.788,-17.867 -25.449,-26.528c-0.461,0.245 -1.397,0.735 -1.858,0.98c-1.83,-2.853 -3.703,-5.663 -5.461,-8.531Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M534.169,464.594c2.896,-1.268 5.807,-2.551 8.675,-3.905c3.185,0.288 6.21,1.397 9.223,2.42c1.988,0.736 3.962,1.499 5.965,2.219c11.558,4.54 23.114,9.684 32.711,17.724c-7.321,-2.161 -14.583,-4.626 -22.205,-5.476c-1.456,-0.316 -2.897,-0.648 -4.323,-0.993c-6.484,-2.825 -13.531,-3.776 -20.549,-3.704c1.369,1.902 2.768,3.805 4.15,5.707c-1.383,0.345 -2.781,0.692 -4.164,1.037c-5.663,2.767 -7.163,-5.101 -11.658,-6.759c1.455,1.946 2.997,3.833 4.495,5.764c-3.443,2.162 -6.268,-1.109 -9.207,-2.464c-2.608,-1.628 -5.822,-1.325 -8.761,-1.47c2.824,-0.663 5.649,-1.353 8.502,-1.959c3.516,-0.734 7.061,-1.268 10.62,-1.83c0.187,-1.239 0.591,-3.703 0.778,-4.942c4.567,0.663 9.122,1.484 13.689,2.146c-5.893,-1.584 -11.888,-2.781 -17.94,-3.516Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M654.088,500.863c2.335,-3.501 4.597,-7.176 8.185,-9.568c-0.822,1.325 -1.628,2.666 -2.421,4.006c-2.017,3.387 -4.323,6.6 -7.06,9.439c-1.887,2.003 -3.69,4.092 -5.346,6.297c-0.216,-3.949 2.607,-6.629 5.388,-8.962c0.317,-0.303 0.938,-0.908 1.254,-1.211Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M532.382,510.402c15.591,-12.017 38.762,-13.315 55.175,-2.218c10.014,6.311 16.672,16.859 19.957,28.07c0.376,9.12 1.413,18.574 -1.772,27.349c-1.859,-5.908 -1.672,-12.595 -5.418,-17.781c-10.058,-15.65 -26.025,-27.927 -44.368,-31.861c-7.681,-2.464 -15.98,-0.778 -23.575,-3.559Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M495.263,526.397c1.124,2.695 2.104,5.448 2.421,8.373c-0.663,4.208 -1.196,8.444 -1.225,12.709c0.115,3.719 0.62,7.407 1.167,11.097c-0.937,0.604 -1.845,1.252 -2.738,1.93l-1.225,0.894c-0.951,-3.429 -2.104,-6.787 -3.631,-10.001c-3.199,1.514 -6.701,1.946 -10.159,1.211c-0.562,0.865 -1.11,1.729 -1.643,2.608c-1.888,2.117 -3.732,4.25 -5.562,6.426c-2.882,-2.708 -5.721,-5.461 -8.804,-7.94c0.375,-3.833 0.36,-8.689 4.51,-10.49c4.366,-2.32 8.689,0.793 11.427,4.077c0.548,-0.792 1.614,-2.377 2.147,-3.17c1.614,0.015 3.242,0.015 4.856,0.044c0.029,-2.161 0.072,-4.309 0.115,-6.456c1.902,-0.576 3.819,-1.138 5.735,-1.7c0.85,-3.198 1.758,-6.398 2.608,-9.612Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M543.017,552.293c-1.009,-5.232 1.081,-10.073 4.712,-13.748c0.518,3.502 0.634,7.133 1.858,10.506c2.421,5.302 8.128,9.265 14.122,8.516c5.548,0.331 8.992,-4.683 13.776,-6.513c-1.584,4.856 -3.011,10.332 -7.651,13.214c-0.922,0.331 -2.781,0.979 -3.703,1.311c-1.744,0.303 -3.488,0.547 -5.246,0.72c-7.204,-0.417 -13.616,-4.841 -17.047,-11.11c-0.215,-0.72 -0.619,-2.176 -0.821,-2.895Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M437.466,555.219c0.115,0.749 0.346,2.262 0.461,3.011c1.931,-0.086 3.876,-0.158 5.822,-0.23c0.187,1.037 0.562,3.126 0.749,4.164c-1.513,0.822 -3.04,1.615 -4.582,2.392c-1.11,-1.944 -1.859,-4.063 -2.536,-6.181c-0.807,0.13 -2.435,0.389 -3.257,0.519c0.533,-0.692 1.599,-2.061 2.133,-2.753c0.303,-0.23 0.908,-0.691 1.21,-0.922Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M445.967,559.006c2.233,-0.619 4.467,-1.498 6.83,-1.456c2.089,2.335 -0.072,4.857 -1.268,6.961c-1.945,-1.744 -3.79,-3.589 -5.562,-5.505Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M487.38,587.194c3.545,-20.678 29.54,-32.509 47.51,-21.715c2.19,1.397 4.294,2.954 6.326,4.61c1.225,1.369 2.348,2.81 3.501,4.295c-7.435,0.36 -14.9,-1.399 -22.277,0.072c-11.802,2.377 -23.012,8.79 -30.174,18.589c-2.248,3.905 -2.478,8.573 -3.559,12.867c-1.47,-5.36 -2.291,-10.936 -1.801,-16.498c0.115,-0.562 0.36,-1.672 0.475,-2.219Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M625.167,584.901c-3.198,-0.922 -0.086,-6.499 1.99,-6.743l0.101,0.792c-0.692,1.874 0.158,5.03 -2.091,5.951Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M408.287,584.442c4.047,-2.616 0.564,4.799 0,0Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M528.434,584.6c0.519,0.562 1.556,1.671 2.075,2.218c1.83,3.618 2.68,7.638 3.213,11.644c-0.504,1.195 -1.066,2.363 -1.686,3.516c-1.052,1.369 -2.147,2.739 -3.3,4.035c-2.219,1.296 -4.582,2.348 -7.032,3.084c-1.902,-0.029 -3.79,-0.116 -5.677,-0.274c-2.637,-1.096 -5.144,-2.493 -7.551,-4.02l-0.259,-0.591c0.158,-0.778 0.461,-2.349 0.62,-3.141c5,1.729 11.153,4.048 15.62,-0.216c5.26,-3.732 4.856,-10.605 3.977,-16.254Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M560.223,595.319c0.259,2.276 0.505,4.553 0.606,6.859c-0.663,3.3 -1.527,6.557 -2.335,9.814c9.179,-0.015 18.473,-1.024 27.033,-4.54c4.336,-1.83 8.472,-4.063 12.507,-6.455c2.493,-0.159 5,-0.216 7.508,-0.376c-0.894,1.514 -1.773,3.027 -2.695,4.525c2.796,2.853 6.254,4.9 9.524,7.148c-0.173,-1.125 -0.331,-2.249 -0.475,-3.358c0.938,-1.081 1.889,-2.148 2.854,-3.214c2.607,1.153 7.277,1.441 6.627,5.419c0.707,4.452 -4.38,4.222 -7.305,5.014c-0.288,6.038 7.593,3.877 10.418,7.666c-4.279,4.943 -7.104,12.667 -1.628,17.897c-1.023,1.211 -2.047,2.421 -3.055,3.646c-4.799,-1.786 -10.203,-2.565 -13.805,-6.528c-0.49,1.946 -0.519,4.079 -1.542,5.85c-1.685,1.889 -4.309,0.418 -6.383,0.361c0.619,4.235 4.208,6.916 6.715,10.071c1.527,0.389 3.055,0.778 4.582,1.182c-2.003,6.629 6.469,9.237 6.902,15.361c2.19,6.816 6.44,12.896 7.507,20.101c5.246,21.974 -7.824,46.241 -28.546,54.814c-7.464,3.142 -16.326,4.338 -23.949,1.11c-6.484,-3.214 -11.887,-9.525 -12.392,-16.946c-0.98,-11.571 8.891,-22.754 20.505,-23.215c8.731,-0.547 17.205,5.058 20.331,13.214c4.958,-6.181 9.237,-13.43 9.454,-21.572c0.806,-9.135 -2.306,-17.998 -6.412,-26.009c2.954,7.811 6.181,15.837 5.432,24.381c0.158,7.392 -3.545,13.964 -7.435,19.973c-1.615,-1.685 -3.214,-3.358 -4.813,-5.03c10.62,-11.066 8.834,-28.66 1.081,-40.779c-2.882,3.675 -3.315,8.387 -4.712,12.681c-1.917,6.109 -5.505,11.643 -10.131,16.067c-0.244,0.202 -0.72,0.606 -0.951,0.822c5.52,1.915 11.484,4.265 14.238,9.856c-4.683,-2.017 -9.266,-4.914 -14.554,-4.712c-11.009,-0.764 -21.082,8.112 -23.185,18.646c-0.606,3.833 -0.159,7.71 -0.159,11.585c-3.545,-5.504 -4.668,-12.435 -2.867,-18.732c1.138,-4.683 4.496,-8.286 7.365,-11.989c-1.067,0.606 -2.105,1.254 -3.128,1.931c-3.876,-2.882 -7.982,-5.476 -11.455,-8.834c-0.303,-0.259 -0.908,-0.764 -1.211,-1.023c-7.349,1.643 -15.187,2.579 -22.205,-0.822c-0.259,-1.195 -0.778,-3.587 -1.038,-4.784c5.706,5.808 14.15,5.259 21.485,3.69c-0.246,-2.291 -0.505,-4.582 -0.749,-6.874c-0.707,-0.461 -2.148,-1.412 -2.867,-1.873c0.922,-5 1.21,-10.375 -2.017,-14.655c1.397,-0.244 2.794,-0.475 4.193,-0.692c0.764,1.211 1.527,2.436 2.306,3.66c1.109,-0.995 2.233,-1.988 3.372,-2.968c-3.747,-3.516 -7.277,-7.306 -9.9,-11.729c2.017,0.878 4.077,1.728 6.139,2.593c-0.98,-2.306 -1.96,-4.61 -2.926,-6.916l0.274,0.215c3.401,3.387 7.983,4.756 12.725,4.208c-1.426,3.069 -2.825,6.153 -3.919,9.367c2.493,-4.309 3.385,-9.957 8.371,-12.22c0.534,0.865 1.052,1.744 1.557,2.623c1.325,-3.66 2.882,-7.248 4.077,-10.952c1.557,-7.723 1.369,-16.326 -2.695,-23.301c-3.703,-2.334 -4.309,3.891 -5.23,6.269c-1.685,-4.178 -3.141,-8.444 -4.251,-12.81c-3.356,3.458 -6.469,7.248 -10.374,10.115c-3.3,1.975 -7.047,2.998 -10.606,4.38c-0.072,1.889 -0.13,3.791 -0.173,5.692c-3.098,1.426 -5.735,3.617 -8.689,5.289c-3.084,0.98 -6.355,1.067 -9.554,1.109c-4.582,-2.032 -8.026,-5.735 -11.254,-9.452c0.173,-1.672 0.346,-3.358 0.504,-5.03c-3.891,3.185 -6.729,9.021 -12.637,8.215c-5.101,0.013 -8.026,-4.569 -10,-8.604c-1.499,0.894 -2.983,1.788 -4.467,2.68c0.202,-0.993 0.605,-2.968 0.793,-3.962c1.182,-0.044 3.53,-0.145 4.698,-0.202c-0.519,-1.974 -1.023,-3.919 -1.542,-5.865c-0.086,-1.845 -0.158,-3.674 -0.245,-5.489c0.85,-0.13 2.536,-0.361 3.372,-0.477c-0.332,-4.366 -1.845,-8.487 -3.631,-12.435c2.075,2.508 3.948,5.159 5.75,7.868c1.585,2.377 3.343,4.641 5.216,6.816c2.003,2.133 4.222,4.064 6.571,5.836c2.147,1.399 4.381,2.68 6.7,3.804c3.862,1.744 8.012,2.68 12.205,3.214c17.551,1.959 35.563,-9.482 41.024,-26.312c0.663,-2.407 1.225,-4.857 1.801,-7.277c0.549,-1.557 1.14,-3.099 1.773,-4.626Zm35.377,13.963c-0.793,0.116 -2.349,0.345 -3.142,0.461c1.283,0.62 2.579,1.211 3.906,1.773c-2.882,3.76 -2.032,8.544 1.239,11.672c-4.121,1.57 -8.516,2.176 -12.868,1.254l-1.426,-0.749c-0.058,-1.283 -0.116,-2.551 -0.173,-3.82c-0.086,0.923 -0.274,2.739 -0.361,3.66c-5.058,-1.643 -8.703,-5.577 -13.199,-8.184c3.978,4.437 7.624,10.273 12.999,12.766c6.844,2.652 14.596,3.229 21.355,-0.057l-0.549,-0.116c5.274,-2.291 8.516,-9.006 5.491,-14.266c-1.917,-4.02 -6.902,-4.826 -10.793,-3.631c-0.576,-1.412 -1.268,-2.897 -2.032,-4.178c-0.101,0.85 -0.332,2.565 -0.446,3.415Zm-13.301,4.409c-0.835,2.262 -2.737,4.251 -3.659,0.734c-0.462,0.347 -1.384,1.067 -1.845,1.413c1.167,1.353 2.335,2.693 3.516,4.063c2.739,-2.723 5.908,-5.072 8.099,-8.285c-2.061,0.634 -4.093,1.325 -6.111,2.075Zm-25.908,3.069c5.028,4.611 9.972,9.351 13.876,15c2.205,3.747 4.15,7.651 5.922,11.629c1.6,4.856 2.594,9.856 3.17,14.928c-0.173,4.05 -0.606,8.084 -1.426,12.061c-0.908,2.017 -1.816,4.05 -2.724,6.082c-2.838,-3.646 -6.6,-7.148 -11.571,-6.975c-8.847,-0.648 -16.759,6.513 -17.55,15.231c0.446,2.781 0.951,5.577 1.614,8.329c5.201,10.721 20.346,13.863 29.8,7.018c6.6,-4.841 11.224,-12.479 12.766,-20.447c4.914,-16.889 -2.045,-34.655 -10.951,-48.965l-0.505,0.057c-3.646,-5.36 -8.213,-10.245 -13.675,-13.746c-2.897,-0.461 -5.85,-0.231 -8.747,-0.202Zm13.574,40.996c-1.023,2.853 -1.124,6.412 -0.072,9.28c1.974,-1.628 1.873,-7.536 0.072,-9.28Zm41.313,15.418l0.634,1.225l-2.867,0.721l-0.202,-0.534c1.557,7.148 4.409,14.165 3.805,21.643c0.36,12.58 -7.076,23.964 -16.659,31.529c0.433,-6.917 -3.516,-13.891 -10.231,-16.168c-10.994,-4.079 -23.185,5.201 -23.949,16.456c-1.166,10.433 9.065,19.367 19.137,18.66c22.565,-0.23 41.861,-22.45 40.275,-44.757c0.259,-10.418 -3.976,-20.418 -9.943,-28.776Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M467.828,613.417c0.461,-0.417 1.355,-1.252 1.816,-1.671c2.435,1.023 4.669,2.407 6.859,3.905c-1.037,1.298 -2.06,2.579 -3.055,3.905c-1.873,-0.057 -3.746,-0.086 -5.605,-0.201l-0.519,-0.433c0.13,-1.845 0.288,-3.688 0.504,-5.505Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M446.212,641.213c-2.666,3.458 -6.34,-2.132 -2.162,-3.343c0.533,0.837 1.614,2.508 2.162,3.343Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M495.868,653.219c2.378,-2.177 4.424,-4.914 7.45,-6.21c2.651,-0.563 5.375,-0.448 8.069,-0.404c3.285,2.666 6.845,5.979 6.384,10.662c0.706,4.409 -2.723,7.58 -5.26,10.664c1.009,0.634 2.032,1.268 3.055,1.931c-0.418,0.663 -1.225,1.988 -1.628,2.651l-2.378,-1.643c-0.086,-0.057 -0.274,-0.173 -0.375,-0.23c-2.623,-1.816 -5.836,-1.614 -8.848,-1.441c-1.11,2.234 -2.219,4.481 -3.703,6.513c-3.156,-1.499 -5.865,-4.18 -7.104,-7.464l-0.144,-0.692c0.014,-1.197 0.029,-3.589 0.029,-4.784l0.101,-0.734c0.519,-0.519 1.571,-1.571 2.089,-2.091l0.504,-0.288l-0.058,-0.489l-0.576,-0.073c-0.893,-0.173 -2.666,-0.503 -3.559,-0.677l1.167,-1.195l0.591,-0.49c0.519,-0.345 1.556,-1.052 2.061,-1.412c1.009,0.547 2.032,1.109 3.041,1.671c-0.303,-1.267 -0.605,-2.521 -0.908,-3.775Z" style="fill:#050708;fill-rule:nonzero;"/><path d="M574.157,434.348c0.809,0.737 0.809,0.737 0,0Z" style="fill:#212122;fill-rule:nonzero;"/><path d="M597.169,445.833c0.854,0.694 0.854,0.694 0,0Z" style="fill:#212122;fill-rule:nonzero;"/><path d="M593.019,434.377c0.781,0.708 0.781,0.708 0,0Z" style="fill:#101010;fill-rule:nonzero;"/><path d="M610.57,446.105l0.044,0.103l0,0.103c0,-0.059 -0.029,-0.148 -0.044,-0.207Z" style="fill:#101010;fill-rule:nonzero;"/><path d="M609.36,455.632l0.361,0.275l-0.13,0.39c-0.057,-0.159 -0.174,-0.491 -0.231,-0.665Z" style="fill:#101010;fill-rule:nonzero;"/><path d="M608.724,434.32c4.323,-2.298 0.275,4.814 0,0Z" style="fill:#696c71;fill-rule:nonzero;"/><path d="M442.697,435.53c5.922,5.922 10.937,12.94 18.257,17.292c0.591,0.389 1.772,1.182 2.363,1.585l-0.605,1.052c-1.787,-0.173 -3.559,-0.476 -5.346,-0.519c-2.608,1.398 -3.934,4.28 -5.577,6.585c-7.003,-4.971 -13.387,-10.836 -19.107,-17.234c3.257,-3.012 6.6,-5.922 10.015,-8.761Z" style="fill:#506a83;fill-rule:nonzero;"/><path d="M548.667,435.703l0.015,0l0.159,0.247c-0.044,-0.058 -0.13,-0.189 -0.174,-0.247Z" style="fill:#111112;fill-rule:nonzero;"/><path d="M592.919,440.471l0.347,0.087l-0.13,0.145c-0.057,-0.058 -0.174,-0.173 -0.216,-0.231Z" style="fill:#111112;fill-rule:nonzero;"/><path d="M614.431,436.984c2.594,-1.758 5.706,-1.167 8.602,-0.677c0.029,5.447 -0.015,10.908 -0.418,16.37c-0.375,1.196 -0.764,2.392 -1.181,3.574c-0.591,0.375 -1.758,1.11 -2.349,1.47c-0.418,-2.594 -5.447,-2.248 -3.559,0.576c0.663,0.072 2.003,0.231 2.68,0.303c-0.993,0.951 -2.003,1.887 -2.996,2.825l-0.534,-0.519c-2.464,-3.084 -0.907,-7.45 -1.369,-11.11l0.015,-0.721l-0.692,-0.029l-0.043,0.721c-0.072,1.095 -0.202,3.285 -0.274,4.38c-1.125,-0.202 -3.387,-0.591 -4.51,-0.793c1.008,-1.571 2.017,-3.17 3.011,-4.77c-0.057,-0.562 -0.143,-1.715 -0.201,-2.277l0,-0.101l-0.044,-0.101l-0.936,-2.81l-0.62,-1.816c0.85,0.158 2.537,0.447 3.387,0.591l0.663,2.219l1.426,-0.072c-2.104,-2.205 -1.744,-4.914 -0.057,-7.234Z" style="fill:#496075;fill-rule:nonzero;"/><path d="M554.92,440.097c1.096,-1.311 2.176,-2.623 3.227,-3.963c3.027,6.398 8.978,10.476 14.209,14.929c3.804,4.568 8.487,8.329 12.004,13.156c-10.693,-6.917 -19.367,-16.398 -29.44,-24.122Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M643.495,451.612l0.317,-0.576c1.397,1.11 2.594,2.406 3.587,3.905c-0.316,0.331 -0.951,1.009 -1.267,1.34c-0.677,-0.259 -2.032,-0.764 -2.709,-1.023c0.029,-0.908 0.057,-2.738 0.072,-3.646Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M492.077,489.178l0.605,1.643c1.499,4.323 4.179,8.617 8.675,10.245c1.787,0.49 3.602,0.907 5.433,1.267c-0.115,1.226 -0.259,2.436 -0.375,3.675c-3.127,-1.311 -6.542,-1.47 -9.871,-1.456c-6.095,-0.375 -12.047,-1.757 -18.012,-2.895c-6.067,-1.11 -11.917,-3.041 -17.825,-4.655c1.7,-0.606 3.43,-1.167 5.159,-1.7c7.911,3.026 16.312,4.222 24.612,5.678c2.493,0.677 5.043,0.979 7.623,1.166c-2.19,-2.579 -4.467,-5.1 -6.701,-7.637c0.173,-1.34 0.519,-3.991 0.677,-5.331Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M619.851,507.765c0.576,-0.259 1.729,-0.764 2.291,-1.008c1.974,1.628 2.291,4.611 -0.707,5.274c-0.432,-1.311 -2.954,-3.012 -1.584,-4.266Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M515.307,552.263c-2.133,-15.721 4.481,-32.177 17.076,-41.861c7.594,2.781 15.894,1.096 23.575,3.559c18.343,3.934 34.31,16.211 44.367,31.86c3.747,5.188 3.559,11.874 5.419,17.783c-2.998,7.896 -8.07,14.985 -14.626,20.331c-8.272,6.774 -18.791,9.784 -29.223,11.053c-2.478,3.545 4.323,2.162 5.836,3.559l0.015,0.244c-2.652,-0.835 -4.352,-0.375 -5.116,1.426c1.037,1.485 3.343,1.14 4.942,1.701c-2.247,0.259 -4.509,0.23 -6.743,0.259c-0.101,-2.306 -0.347,-4.582 -0.606,-6.859c-0.634,1.527 -1.225,3.069 -1.772,4.625c0.015,-0.907 0.072,-2.723 0.101,-3.631c0.028,-2.247 0,-4.496 -0.116,-6.743c-0.36,-2.263 -0.764,-4.496 -1.34,-6.7c-0.36,-0.923 -1.052,-2.768 -1.399,-3.675c-1.037,-2.464 -1.368,-5.116 -1.34,-7.752c3.761,5.865 6.01,12.522 7.365,19.323c1.166,-0.129 3.516,-0.404 4.683,-0.533c1.181,-0.187 3.545,-0.549 4.726,-0.736c1.96,-0.446 3.906,-0.979 5.85,-1.599c5.52,-2.205 10.39,-5.75 14.669,-9.842c3.473,-3.646 8.314,-8.387 6.139,-13.891c-2.508,-7.796 -6.974,-14.827 -12.061,-21.183c-1.83,-2.407 -4.554,-3.804 -7.191,-5.144c-3.141,-5.202 -8.386,-8.733 -14.309,-9.972c-9.755,-6.772 -22.435,-6.499 -33.776,-5.779c-7.118,0.375 -9.323,8.602 -11.067,14.223c-1.124,5.216 -1.542,10.561 -1.917,15.879l-2.161,0.072Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M641.925,549.727l0.23,1.298c-0.057,1.946 -0.619,3.775 -1.656,5.491c1.656,0.936 3.429,1.555 5.259,2.132l0.446,1.47c-2.594,-0.505 -5.173,-0.951 -7.767,-1.384c1.167,-2.996 2.377,-5.979 3.488,-9.006Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M548.479,603.432l0.806,0.505l0.317,1.096c-2.104,6.728 -7.292,11.657 -12.09,16.527c-0.49,-0.519 -0.966,-1.023 -1.441,-1.542c1.628,-1.254 3.214,-2.535 4.784,-3.862c1.283,-1.383 2.493,-2.867 3.646,-4.366c0.461,-0.808 1.399,-2.436 1.859,-3.257c0.764,-1.672 1.412,-3.401 2.118,-5.101Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M555.25,633.578c1.296,1.988 2.55,4.035 3.775,6.095c-0.72,2.003 -1.412,4.035 -2.089,6.067c-5.331,-4.338 -15.332,-3.905 -16.845,4.035c0.244,1.412 0.547,2.824 0.907,4.237c0.275,0.663 0.808,1.974 1.081,2.622c0.966,2.306 1.946,4.611 2.926,6.917c-2.061,-0.865 -4.121,-1.715 -6.139,-2.594c-0.648,-0.49 -1.917,-1.456 -2.551,-1.931c0.879,-0.591 1.773,-1.167 2.667,-1.729c-0.073,-4.424 0.057,-8.832 0.331,-13.228c3.141,-1.555 6.398,-2.867 9.741,-3.934c1.917,0.231 3.833,0.433 5.75,0.62c0.404,0.576 1.211,1.729 1.615,2.306c-0.477,-3.156 -0.894,-6.311 -1.167,-9.482Z" style="fill:#1d1e1e;fill-rule:nonzero;"/><path d="M576.737,437.028c4.784,0.043 -2.068,4.423 0,0Z" style="fill:#363739;fill-rule:nonzero;"/><path d="M470.364,627.366c0.824,0.738 0.824,0.738 0,0Z" style="fill:#080808;fill-rule:nonzero;"/><path d="M596.046,436.626c0.345,0.332 1.023,1.009 1.355,1.355l0.389,2.234c1.656,-1.225 3.328,-2.435 5.058,-3.574c-0.549,1.902 -1.801,3.415 -3.2,4.77c0.591,1.527 1.182,3.055 1.788,4.582c-1.889,-2.637 -3.891,-5.259 -6.5,-7.234c0.288,-0.533 0.837,-1.599 1.11,-2.133Z" style="fill:#222;fill-rule:nonzero;"/><path d="M522.438,574.455c7.378,-1.47 14.842,0.288 22.277,-0.072l0.519,0.692c1.225,1.772 2.377,3.602 3.444,5.489c2.651,5.52 3.3,11.787 2.464,17.825c-0.23,1.254 -0.677,3.761 -0.907,5.03c-0.375,-2.19 -0.736,-4.382 -0.879,-6.586c-0.173,-4.423 0.202,-9.524 -3.099,-12.983c-6.08,-3.617 -13.473,-2.104 -20.173,-2.636c-7.652,-3.243 -17.306,-0.088 -20.087,8.112c-0.418,0.505 -1.239,1.527 -1.657,2.032c-4.107,4.336 -8.819,8.573 -10.779,14.409c-0.994,2.767 1.081,5.289 2.363,7.565c2.06,2.407 4.352,4.626 6.845,6.572c2.723,1.513 5.533,2.867 8.444,4.006c1.124,0.173 3.343,0.547 4.467,0.72c1.066,0.058 3.213,0.187 4.294,0.246c1.182,-0.086 3.53,-0.246 4.712,-0.332c3.804,-0.764 7.392,-2.291 10.923,-3.905c-0.303,0.634 -0.908,1.902 -1.211,2.55c-3.4,1.052 -6.772,2.162 -10.26,2.854c-8.026,0.475 -16.283,-0.677 -23.113,-5.173c-1.917,-1.312 -3.775,-2.724 -5.533,-4.222c-2.795,-2.867 -4.928,-6.298 -6.528,-9.944l-0.259,-0.792c1.081,-4.294 1.311,-8.964 3.559,-12.868c7.162,-9.799 18.372,-16.211 30.174,-18.589Z" style="fill:#222;fill-rule:nonzero;"/><path d="M576.894,601.617c0.98,-0.015 2.926,-0.044 3.891,-0.058c-0.029,1.889 0.331,4.121 -1.946,4.799c-1.383,-1.225 -1.571,-3.069 -1.946,-4.74Z" style="fill:#222;fill-rule:nonzero;"/><path d="M611.507,437.561c0.825,0.781 0.825,0.781 0,0Z" style="fill:#7d8086;fill-rule:nonzero;"/><path d="M587.053,438.526c0.766,0.752 0.766,0.752 0,0Z" style="fill:#c9d8e5;fill-rule:nonzero;"/><path d="M658.643,438.197c6.268,0.303 16.037,-0.259 17.55,7.825c-2.895,-0.778 -5.706,-1.787 -8.632,-2.363c-2.521,2.349 -4.74,5 -7.262,7.349c-0.274,-4.265 2.638,-9.741 -1.656,-12.81Z" style="fill:#4b6fb2;fill-rule:nonzero;"/><path d="M584.288,441.438c0.78,0.795 0.78,0.795 0,0Z" style="fill:#0f0f0f;fill-rule:nonzero;"/><path d="M588.841,441.235c2.421,-0.922 4.799,2.853 3.486,4.87c-1.887,-0.115 -3.933,-3.026 -3.486,-4.87Z" style="fill:#5b5d61;fill-rule:nonzero;"/><path d="M581.319,443.08c0.723,0.752 0.723,0.752 0,0Z" style="fill:#424346;fill-rule:nonzero;"/><path d="M594.258,442.737c4.206,-2.428 0.462,4.799 0,0Z" style="fill:#bcc7d0;fill-rule:nonzero;"/><path d="M537.816,444.088c5.735,3.17 11.585,6.168 17.579,8.833c-1.311,2.637 -2.608,5.274 -3.919,7.926c-5.807,-1.902 -11.615,-3.862 -17.249,-6.254c1.138,-3.53 2.335,-7.018 3.589,-10.505Z" style="fill:#f49426;fill-rule:nonzero;"/><path d="M625.702,444.046c1.902,-0.058 3.818,-0.115 5.75,-0.173c-0.159,2.147 -0.36,4.28 -0.606,6.427c-0.606,4.669 -1.225,9.352 -2.089,13.991c-1.7,0.101 -3.387,0.231 -5.072,0.347c1.195,-6.802 1.988,-13.675 2.017,-20.592Z" style="fill:#435566;fill-rule:nonzero;"/><path d="M659.204,454.694c2.493,-3.113 5.144,-6.11 7.867,-9.035c-0.663,4.654 -1.311,9.352 -2.939,13.79c-1.786,4.625 -3.775,9.179 -5.893,13.675c-1.83,3.848 -3.704,7.709 -6.139,11.211c-1.096,-1.138 -2.177,-2.263 -3.257,-3.387c4.265,-8.386 8.257,-17.032 10.361,-26.254Z" style="fill:#435566;fill-rule:nonzero;"/><path d="M640.715,500.907c3.732,-4.712 6.643,-10.001 10.159,-14.857c4.496,-0.231 8.516,1.83 12.494,3.631c-0.274,0.404 -0.822,1.21 -1.096,1.614c-3.587,2.392 -5.85,6.067 -8.184,9.568c-0.317,0.303 -0.938,0.908 -1.254,1.211c-1.384,-1.153 -2.84,-2.205 -4.38,-3.113c-2.638,0.418 -5.173,1.254 -7.739,1.946Z" style="fill:#435566;fill-rule:nonzero;"/><path d="M500.365,445.875c1.715,0.216 3.43,0.432 5.159,0.663c3.458,4.957 6.513,10.173 9.467,15.433c-5.447,2.579 -10.995,5 -15.995,8.43c-6.7,-3.516 -12.911,-7.868 -19.208,-12.047c1.715,-1.657 3.415,-3.329 5.144,-4.971l0.259,-0.259c2.118,1.455 4.28,2.868 6.513,4.164c0.101,-2.075 0.216,-4.136 0.332,-6.196l-2.219,-0.014l1.095,-1.081c0.447,-0.418 1.34,-1.239 1.787,-1.643c0.086,0.504 0.274,1.499 0.375,2.003c-0.014,2.565 -0.072,5.159 0.346,7.695c0.159,0.403 0.476,1.21 0.634,1.614c2.551,1.614 5.389,2.724 8.257,3.689c-0.965,-1.658 -1.902,-3.3 -2.925,-4.886c-1.398,-2.003 -2.896,-3.919 -4.438,-5.793c1.772,-2.291 3.588,-4.553 5.418,-6.801Z" style="fill:#394653;fill-rule:nonzero;"/><path d="M667.071,445.659c4.424,0.043 8.589,1.671 12.767,2.954c-1.384,5.692 -3.227,11.24 -4.971,16.816c-3.429,-2.262 -7.018,-4.25 -10.735,-5.98c1.628,-4.438 2.276,-9.136 2.939,-13.79Z" style="fill:#5b7fa3;fill-rule:nonzero;"/><path d="M594.088,447.287c4.784,0.188 -2.182,4.351 0,0Z" style="fill:#585a5e;fill-rule:nonzero;"/><path d="M607.4,447.446c0.796,0.781 0.796,0.781 0,0Z" style="fill:#0d0d0d;fill-rule:nonzero;"/><path d="M440.146,500.908c0.795,0.765 0.795,0.765 0,0Z" style="fill:#0d0d0d;fill-rule:nonzero;"/><path d="M536.82,664.92c2.176,-0.576 5.23,1.729 5.144,4.02c-2.104,0.345 -5.432,-1.744 -5.144,-4.02Z" style="fill:#0d0d0d;fill-rule:nonzero;"/><path d="M590.108,449.984c4.235,-2.4 0.42,4.77 0,0Z" style="fill:#a1a7ad;fill-rule:nonzero;"/><path d="M604.49,450.084c0.781,0.766 0.781,0.766 0,0Z" style="fill:#1e1e1f;fill-rule:nonzero;"/><path d="M547.902,634.874c1.98,-0.651 2.732,0.044 2.27,2.096c-1.995,0.651 -2.747,-0.058 -2.27,-2.096Z" style="fill:#1e1e1f;fill-rule:nonzero;"/><path d="M612.588,449.766l0.721,0.029c0.461,3.66 -1.096,8.026 1.369,11.109l0.533,0.519c0.303,0.303 0.908,0.908 1.197,1.21c-0.576,-0.057 -1.758,-0.173 -2.349,-0.23c-4.668,-1.845 -2.464,6.844 -7.003,4.178c0.303,-3.545 1.542,-6.902 2.537,-10.288l0.129,-0.389l-0.36,-0.274c-0.835,-0.634 -2.493,-1.931 -3.314,-2.565l1.757,0.288c1.125,0.202 3.387,0.591 4.51,0.793c0.073,-1.095 0.202,-3.285 0.274,-4.381Z" style="fill:#fffcfd;fill-rule:nonzero;"/><path d="M628.754,464.291c0.865,-4.64 1.485,-9.323 2.091,-13.991c0.475,6.167 6.642,8.314 11.512,10.274c-0.028,0.936 -0.072,2.796 -0.086,3.732c-0.114,1.672 -0.259,3.343 -0.446,5.015c-0.475,-4.05 -3.776,0.158 -0.475,1.094l0.519,0.677c-4.943,0.058 -8.791,-4.063 -13.834,-3.486c0.173,-0.837 0.533,-2.493 0.72,-3.315Z" style="fill:#4e6880;fill-rule:nonzero;"/><path d="M493.073,450.356c0.461,0.576 1.412,1.744 1.873,2.32c1.542,1.873 3.041,3.79 4.438,5.793c-1.499,-0.101 -4.481,-0.317 -5.966,-0.418c-0.418,-2.536 -0.36,-5.13 -0.346,-7.695Z" style="fill:#83a6c6;fill-rule:nonzero;"/><path d="M595.857,451.567c0.708,0.766 0.708,0.766 0,0Z" style="fill:#444649;fill-rule:nonzero;"/><path d="M601.279,451.481c4.741,0.246 -2.211,4.365 0,0Z" style="fill:#46484b;fill-rule:nonzero;"/><path d="M555.395,452.923c5.491,2.507 10.851,5.274 16.182,8.113c-0.865,2.319 -1.729,4.654 -2.579,6.989c-5.676,-2.781 -11.527,-5.202 -17.522,-7.176c1.311,-2.652 2.608,-5.289 3.919,-7.926Z" style="fill:#f27e25;fill-rule:nonzero;"/><path d="M648.743,452.174c2.983,0.749 5.88,1.902 7.912,4.337c-1.658,5.057 -3.358,10.102 -5.562,14.957c-2.19,-1.138 -4.367,-2.247 -6.542,-3.372c-0.057,-0.922 -0.202,-2.737 -0.274,-3.66c2.594,-1.786 3.574,-5.446 1.859,-8.155c0.316,-0.331 0.951,-1.009 1.267,-1.34c0.332,-0.692 0.995,-2.075 1.34,-2.767Z" style="fill:#516c87;fill-rule:nonzero;"/><path d="M457.365,454.939c1.787,0.043 3.559,0.346 5.346,0.519l0.605,-1.052c9.741,7.133 19.179,14.698 29.54,20.952c-1.196,2.104 -3.069,4.006 -3.43,6.455c1.066,1.124 2.19,2.205 3.358,3.257c-0.072,1.917 -0.115,3.833 -0.101,5.75l-0.605,-1.643c-0.072,-0.806 -0.231,-2.407 -0.303,-3.213c-0.62,0.173 -1.844,0.49 -2.464,0.648c-1.599,-2.32 -3.257,-4.597 -4.928,-6.844c-0.447,0.576 -1.34,1.729 -1.787,2.306c-1.945,-1.889 -3.934,-3.761 -6.066,-5.433c-2.45,-1.094 -5.029,-1.873 -7.695,-2.19c-4.971,-5.245 -11.946,-7.91 -17.047,-12.926c1.643,-2.306 2.968,-5.187 5.577,-6.585Z" style="fill:#3f4f5e;fill-rule:nonzero;"/><path d="M643.425,455.257c0.677,0.259 2.032,0.764 2.709,1.023c1.715,2.709 0.734,6.369 -1.859,8.155c-0.503,-0.028 -1.498,-0.101 -2.003,-0.129c0.015,-0.936 0.058,-2.796 0.086,-3.732c0.332,-1.773 0.677,-3.545 1.067,-5.318Z" style="fill:#b7d2eb;fill-rule:nonzero;"/><path d="M600.253,457.448c0.709,0.752 0.709,0.752 0,0Z" style="fill:#afb4ba;fill-rule:nonzero;"/><path d="M619.088,457.721c0.591,-0.36 1.757,-1.095 2.348,-1.47c-0.979,10.332 -3.299,20.433 -5.188,30.606c-2.291,0.202 -2.146,4.395 0.317,2.911c0.173,-0.519 0.547,-1.586 0.734,-2.104c2.911,-7.436 5.116,-15.13 6.384,-23.027c1.685,-0.116 3.372,-0.244 5.072,-0.345c-0.187,0.821 -0.547,2.478 -0.721,3.314c-0.951,4.121 -1.858,8.242 -2.809,12.378c-0.793,2.983 -1.527,6.196 -3.949,8.357c-0.576,0.015 -1.744,0.044 -2.32,0.058c0.576,3.17 -4.063,7.752 1.009,8.718c-0.404,0.936 -1.197,2.796 -1.6,3.732c-1.786,-1.687 -3.53,-3.415 -5.245,-5.159l1.153,-0.778c2.81,-0.749 1.21,-4.309 -1.268,-3.328c-2.118,-2.379 -2.781,-5.678 -3.343,-8.718c1.672,-5.303 2.954,-10.708 4.294,-16.081l0,-0.778c0.029,-0.908 0.073,-2.695 0.101,-3.603c0.591,0.058 1.773,0.173 2.349,0.231c-0.288,-0.303 -0.894,-0.908 -1.197,-1.21c0.995,-0.938 2.003,-1.874 2.998,-2.825l0.879,-0.879Z" style="fill:#36424d;fill-rule:nonzero;"/><path d="M494.052,459.666c-0.159,-0.403 -0.476,-1.21 -0.634,-1.614c1.484,0.101 4.467,0.317 5.966,0.418c1.023,1.585 1.96,3.228 2.925,4.884c-2.868,-0.966 -5.706,-2.075 -8.257,-3.688Z" style="fill:#151719;fill-rule:nonzero;"/><path d="M484.613,492.519c1.254,-2.148 7.248,-1.369 4.179,1.296c-1.052,-0.331 -3.141,-0.979 -4.179,-1.296Z" style="fill:#151719;fill-rule:nonzero;"/><path d="M602.992,460.257c0.726,0.741 0.726,0.741 0,0Z" style="fill:#48494c;fill-rule:nonzero;"/><path d="M658.236,473.125c2.118,-4.496 4.108,-9.049 5.894,-13.675c3.717,1.729 7.306,3.717 10.735,5.981c-1.167,2.622 -2.133,5.403 -3.89,7.709c-1.096,0.865 -2.234,1.685 -3.344,2.522c-0.259,0.764 -0.806,2.306 -1.065,3.084c-0.475,-0.793 -1.413,-2.349 -1.874,-3.128c-2.161,-0.835 -4.309,-1.671 -6.456,-2.493Z" style="fill:#53718d;fill-rule:nonzero;"/><path d="M571.576,461.036c5.491,3.084 10.794,6.484 15.866,10.231c-0.778,2.104 -1.542,4.222 -2.291,6.341c-5.101,-3.646 -10.548,-6.801 -16.154,-9.583c0.85,-2.335 1.715,-4.668 2.579,-6.989Z" style="fill:#f16624;fill-rule:nonzero;"/><path d="M599.794,461.813c4.785,0.13 -2.154,4.364 0,0Z" style="fill:#585b5f;fill-rule:nonzero;"/><path d="M421.167,463.514c1.917,1.6 3.819,3.229 5.706,4.885c2.954,-1.498 5.908,-2.954 8.92,-4.336c-1.398,2.895 -2.925,5.735 -3.991,8.775c1.816,2.666 4.179,4.9 6.398,7.233c-0.692,0.044 -2.075,0.13 -2.767,0.189c-2.19,-0.26 -4.366,-0.477 -6.542,-0.664c-2.306,2.623 -4.309,5.491 -6.34,8.329c-0.303,-1.959 -0.548,-3.933 -0.778,-5.893c-0.115,-0.908 -0.331,-2.752 -0.447,-3.66c-1.859,-0.835 -3.761,-1.57 -5.62,-2.377l0.418,-1.34c1.571,-1.096 3.415,-1.83 4.813,-3.156c0.245,-2.666 0.187,-5.331 0.231,-7.983Zm0.893,14.77c0.245,2.335 0.49,4.668 0.749,7.018c1.859,-1.83 3.314,-3.978 4.496,-6.283c2.666,0.073 5.332,0.145 7.997,0.216c-1.989,-2.622 -3.977,-5.245 -5.706,-8.041c-3.199,1.557 -5.548,4.208 -7.536,7.09Z" style="fill:#b9cce7;fill-rule:nonzero;"/><path d="M435.793,464.061c1.196,1.498 2.392,2.983 3.646,4.452c2.464,2.926 5.188,5.678 8.214,8.041c-1.427,1.153 -2.853,2.335 -4.265,3.502c0.476,1.715 0.951,3.444 1.412,5.173c-3.055,-1.788 -6.153,-3.488 -9.366,-4.971c0.692,-0.058 2.075,-0.145 2.767,-0.187c-2.219,-2.335 -4.582,-4.569 -6.398,-7.234c1.066,-3.04 2.594,-5.88 3.991,-8.775Z" style="fill:#5d81a7;fill-rule:nonzero;"/><path d="M540.091,649.775c7.493,0 15.001,0.187 22.495,0.648c-0.865,1.584 -1.816,3.126 -2.796,4.668c-6.254,-0.518 -12.508,-1.397 -18.791,-1.08c-0.36,-1.413 -0.663,-2.825 -0.908,-4.237Z" style="fill:#5d81a7;fill-rule:nonzero;"/><path d="M642.272,464.306c0.505,0.029 1.498,0.101 2.003,0.13c0.072,0.922 0.216,2.737 0.274,3.659c-0.505,0.764 -1.542,2.291 -2.06,3.069l-0.62,-0.072l-0.518,-0.677l0.475,-1.094c0.187,-1.672 0.331,-3.343 0.446,-5.015Z" style="fill:#131516;fill-rule:nonzero;"/><path d="M443.044,465.935c0.694,0.781 0.694,0.781 0,0Z" style="fill:#3a3a3b;fill-rule:nonzero;"/><path d="M448.806,465.992c0.694,0.794 0.694,0.794 0,0Z" style="fill:#424142;fill-rule:nonzero;"/><path d="M517.282,466.87c3.084,-0.648 6.211,-1.109 9.352,-1.412c0.144,2.421 0.274,4.841 0.389,7.277c-2.853,0.606 -5.677,1.296 -8.502,1.959c-0.403,-2.622 -0.821,-5.23 -1.239,-7.824Z" style="fill:#34404b;fill-rule:nonzero;"/><path d="M526.633,465.458c3.934,-0.244 7.882,-0.086 11.787,0.505c-0.187,1.239 -0.591,3.703 -0.778,4.942c-3.559,0.563 -7.103,1.096 -10.619,1.83c-0.115,-2.434 -0.245,-4.856 -0.389,-7.277Z" style="fill:#22282d;fill-rule:nonzero;"/><path d="M613.164,466.035l0.794,-0.028l0,0.78l-0.78,0.015l-0.015,-0.766Z" style="fill:#fef4f8;fill-rule:nonzero;"/><path d="M502.094,474.795c4.525,-3.53 9.64,-6.368 15.188,-7.925c0.418,2.594 0.836,5.202 1.239,7.824c-3.141,0.764 -5.533,2.983 -7.853,5.087c2.392,1.614 4.798,3.229 7.205,4.828c0.403,2.32 0.778,4.639 1.182,6.974c-0.965,0.778 -1.902,1.614 -2.81,2.464c-1.657,1.586 -3.256,3.214 -4.942,4.756c-2.291,-8.286 -5.908,-16.096 -9.208,-24.008Z" style="fill:#516b86;fill-rule:nonzero;"/><path d="M628.035,467.606c5.044,-0.575 8.892,3.545 13.834,3.488l0.619,0.072c0.519,-0.778 1.557,-2.306 2.061,-3.069c2.176,1.125 4.352,2.234 6.542,3.372c-2.335,4.914 -4.828,9.77 -7.739,14.396c-2.565,-1.96 -5.129,-3.89 -7.651,-5.894c0.677,2.695 1.931,5.692 -0.49,7.926c-1.599,-4.54 -5.489,-6.989 -9.985,-7.912c0.951,-4.136 1.858,-8.257 2.809,-12.378Zm12.538,5.303c-1.758,0.835 -3.603,3.17 -2.709,5.173c2.377,1.586 5.605,-4.567 2.709,-5.173Z" style="fill:#475c70;fill-rule:nonzero;"/><path d="M453.633,494.853c2.32,0.822 4.698,1.485 7.075,2.148c5.908,1.614 11.758,3.545 17.825,4.654c-2.248,2.004 -4.222,4.295 -5.922,6.788l-0.922,-0.101c-4.597,-0.534 -9.179,-1.226 -13.79,-1.715l-0.245,-0.044c-2.623,-1.037 -5.187,-2.161 -7.781,-3.227c-0.764,-0.303 -2.306,-0.923 -3.084,-1.239c4.352,0.086 6.96,-3.142 6.845,-7.264Z" style="fill:#475c70;fill-rule:nonzero;"/><path d="M641.354,470.424c-3.31,-0.939 0,-5.16 0.477,-1.098l-0.477,1.098Z" style="fill:#bacedf;fill-rule:nonzero;"/><path d="M439.441,468.513c2.551,1.052 4.871,2.579 7.075,4.237c0.965,-0.952 1.931,-1.889 2.911,-2.84c0.576,0.808 1.7,2.421 2.277,3.214c1.052,-0.865 2.118,-1.744 3.184,-2.608c1.974,1.786 3.934,3.602 5.836,5.461c-0.821,2.521 -1.585,5.807 2.205,5.979c-0.187,0.606 -0.562,1.83 -0.749,2.436c-0.418,-0.015 -1.254,-0.057 -1.671,-0.072c-4.568,-2.104 -8.833,-4.784 -12.854,-7.767c-3.026,-2.364 -5.75,-5.116 -8.214,-8.041Zm9.41,6.038c0.706,0.764 0.706,0.764 0,0Zm2.781,2.996c0.764,0.721 0.764,0.721 0,0Z" style="fill:#39383a;fill-rule:nonzero;"/><path d="M445.823,468.902c0.766,0.737 0.766,0.737 0,0Z" style="fill:#414143;fill-rule:nonzero;"/><path d="M463.274,474.551c0.723,0.752 0.723,0.752 0,0Z" style="fill:#414143;fill-rule:nonzero;"/><path d="M476.53,476.64c2.133,1.672 4.121,3.545 6.067,5.432c0.447,-0.576 1.34,-1.729 1.787,-2.306c1.672,2.249 3.329,4.525 4.928,6.845c0.62,-0.158 1.844,-0.475 2.464,-0.648c0.072,0.806 0.231,2.407 0.303,3.213c-0.159,1.34 -0.504,3.992 -0.677,5.333c-0.648,-0.174 -1.96,-0.519 -2.608,-0.692c3.069,-2.666 -2.925,-3.444 -4.179,-1.298c-2.363,-0.778 -4.77,-1.426 -7.162,-2.06c-3.069,-0.808 -6.124,-1.687 -9.049,-2.897c-1.066,-2.06 -2.104,-4.121 -3.127,-6.196c1.239,1.008 2.478,2.032 3.718,3.055c2.493,-2.608 5.015,-5.202 7.536,-7.782Z" style="fill:#414143;fill-rule:nonzero;"/><path d="M615.73,540.189c0.215,1.571 0.402,3.156 0.562,4.755c0,2.508 -0.13,5.03 -0.361,7.537c-0.244,2.031 -0.59,4.035 -0.878,6.08c0.764,-0.505 2.262,-1.513 3.026,-2.017c0.778,0.418 2.32,1.268 3.084,1.7c0.317,1.167 0.648,2.349 0.98,3.545c-0.995,0.389 -1.99,0.793 -2.968,1.197c-1.615,-1.397 -3.214,-2.781 -4.828,-4.165c-0.317,0.995 -0.966,2.983 -1.296,3.992c-1.931,3.646 -3.66,7.392 -4.842,11.354c-1.484,2.926 -3.242,5.72 -5.591,8.013c-6.412,4.927 -12.651,10.317 -20.26,13.3c-3.976,1.469 -8.285,3.646 -12.595,2.348c3.113,-2.348 7.335,-2.478 10.866,-4.05c14.64,-5.561 25.318,-18.646 29.93,-33.387c0.417,0.418 1.252,1.268 1.671,1.685c2.061,-7.176 0.317,-14.985 3.502,-21.888Z" style="fill:#414143;fill-rule:nonzero;"/><path d="M582.298,613.693c2.017,-0.749 4.05,-1.441 6.109,-2.075c-2.19,3.213 -5.36,5.561 -8.098,8.285c-1.182,-1.369 -2.349,-2.709 -3.516,-4.064c0.461,-0.345 1.383,-1.065 1.843,-1.412c0.923,3.517 2.825,1.527 3.66,-0.734Z" style="fill:#414143;fill-rule:nonzero;"/><path d="M451.602,468.845c0.752,0.723 0.752,0.723 0,0Z" style="fill:#2e2f30;fill-rule:nonzero;"/><path d="M457.41,468.917c0.824,0.649 0.824,0.649 0,0Z" style="fill:#434243;fill-rule:nonzero;"/><path d="M605.571,469.787c0.015,-0.246 0.015,-0.709 0.015,-0.955l-0.015,0.955Z" style="fill:#404245;fill-rule:nonzero;"/><path d="M605.586,468.833c2.891,-2.63 2.776,3.658 -0.015,0.955l0.015,-0.955Z" style="fill:#b3afb5;fill-rule:nonzero;"/><path d="M678.759,468.383c1.138,2.047 2.233,4.107 3.314,6.196c1.816,0.677 3.617,1.327 5.447,2.003c-1.023,1.413 -2.032,2.825 -3.026,4.237c-2.162,-1.21 -4.468,-2.132 -6.816,-2.867l-1.211,0.433c-0.576,0.187 -1.728,0.576 -2.304,0.778c-2.219,-1.096 -4.367,-2.335 -6.528,-3.502c1.109,-0.835 2.247,-1.658 3.343,-2.522l-0.216,1.687c1.384,-0.044 4.178,-0.116 5.577,-0.145c0.793,-2.104 1.599,-4.208 2.421,-6.297Z" style="fill:#c5d5ec;fill-rule:nonzero;"/><path d="M460.391,471.77c0.781,0.708 0.781,0.708 0,0Z" style="fill:#434244;fill-rule:nonzero;"/><path d="M587.442,471.265c10.937,8.604 21.529,18.575 27.956,31.068c-1.744,-1.816 -3.502,-3.617 -5.333,-5.331c-4.639,-4.496 -9.985,-8.156 -15.562,-11.398c-0.576,-0.389 -1.758,-1.197 -2.335,-1.586c-2.334,-2.161 -4.553,-4.423 -7.018,-6.412c0.75,-2.118 1.514,-4.235 2.291,-6.341Z" style="fill:#ef4823;fill-rule:nonzero;"/><path d="M531.995,472.866c4.496,1.658 5.995,9.525 11.657,6.759c-0.143,1.181 -0.432,3.559 -0.562,4.74c-2.68,0.923 -5.346,1.859 -7.94,2.998c-0.173,-1.124 -0.518,-3.387 -0.692,-4.51c0.98,-0.404 2.955,-1.21 3.934,-1.614c-0.634,-0.879 -1.267,-1.744 -1.902,-2.608c-1.498,-1.931 -3.04,-3.818 -4.496,-5.764Z" style="fill:#8d8d8e;fill-rule:nonzero;"/><path d="M652.099,484.334c2.436,-3.501 4.309,-7.363 6.139,-11.211c2.148,0.822 4.295,1.658 6.456,2.493c0.461,0.778 1.397,2.335 1.873,3.128c-0.316,1.109 -0.936,3.358 -1.252,4.481c-0.576,2.176 -1.197,4.338 -1.946,6.456c-3.978,-1.802 -7.998,-3.862 -12.494,-3.632l1.225,-1.715Z" style="fill:#4b6278;fill-rule:nonzero;"/><path d="M413.833,476.539c0.749,-0.649 1.513,-1.268 2.291,-1.889l-0.418,1.34c1.859,0.808 3.761,1.542 5.62,2.377c0.115,0.908 0.331,2.753 0.447,3.66c-2.867,-1.498 -5.447,-3.444 -7.94,-5.489Z" style="fill:#4d5b6a;fill-rule:nonzero;"/><path d="M462.927,481.959c0.677,-3.328 2.161,-6.845 5.908,-7.508c2.666,0.317 5.245,1.096 7.695,2.19c-2.522,2.579 -5.043,5.173 -7.536,7.782c-1.239,-1.023 -2.478,-2.047 -3.718,-3.055c1.023,2.075 2.06,4.136 3.127,6.196c-2.089,-1.037 -4.121,-2.176 -6.225,-3.17c0.187,-0.604 0.562,-1.83 0.749,-2.434Z" style="fill:#212021;fill-rule:nonzero;"/><path d="M510.667,479.781c2.32,-2.104 4.712,-4.323 7.853,-5.087c2.94,0.145 6.153,-0.158 8.761,1.47c2.94,1.355 5.764,4.626 9.208,2.464c0.635,0.865 1.268,1.729 1.902,2.608c-0.979,0.404 -2.954,1.21 -3.934,1.614c-1.801,0.734 -3.602,1.441 -5.389,2.176c-4.553,-2.594 -9.208,-5.159 -14.338,-6.383c0.893,1.197 1.787,2.407 2.709,3.602l0.432,2.364c-2.406,-1.6 -4.813,-3.214 -7.205,-4.828Z" style="fill:#242a2f;fill-rule:nonzero;"/><path d="M608.856,474.68c0.723,0.766 0.723,0.766 0,0Z" style="fill:#c4c9ce;fill-rule:nonzero;"/><path d="M682.072,474.58c2.695,0 5.39,0.114 8.084,0.288c-1.037,2.737 -2.118,5.447 -3.214,8.155l-0.734,-0.663c-0.418,-0.375 -1.283,-1.153 -1.715,-1.542c0.995,-1.412 2.003,-2.824 3.026,-4.235c-1.83,-0.677 -3.631,-1.325 -5.447,-2.003Z" style="fill:#596472;fill-rule:nonzero;"/><path d="M496.487,482.189c1.484,-2.724 3.358,-5.245 5.605,-7.392c3.3,7.912 6.917,15.721 9.208,24.008c-1.47,1.225 -2.954,2.42 -4.51,3.53c-1.83,-0.361 -3.646,-0.778 -5.433,-1.268c0.865,-0.13 2.594,-0.389 3.458,-0.505c-3.545,-5.75 -6.139,-12.004 -8.329,-18.372Z" style="fill:#5e87af;fill-rule:nonzero;"/><path d="M667.631,475.66c2.162,1.166 4.309,2.405 6.528,3.501c0.576,-0.202 1.729,-0.591 2.306,-0.778c-0.389,0.62 -1.195,1.845 -1.599,2.464c-1.268,2.003 -2.508,4.02 -3.646,6.111c-2.104,-1.052 -4.481,-1.758 -5.908,-3.732c0.316,-1.125 0.936,-3.372 1.254,-4.482c0.259,-0.777 0.806,-2.319 1.065,-3.083Z" style="fill:#5d85ad;fill-rule:nonzero;"/><path d="M447.654,476.554c4.02,2.983 8.286,5.663 12.854,7.767c-1.571,1.325 -3.084,2.724 -4.582,4.121c4.784,2.364 9.669,4.626 14.885,5.88c6.528,2.205 14.251,1.917 19.669,6.657c-8.3,-1.454 -16.701,-2.651 -24.612,-5.676c-7.162,-3.055 -14.424,-5.951 -21.067,-10.073c-0.461,-1.729 -0.937,-3.458 -1.412,-5.173c1.412,-1.167 2.839,-2.349 4.265,-3.502Z" style="fill:#526e89;fill-rule:nonzero;"/><path d="M547.815,478.587c2.32,-0.534 4.668,-0.923 7.047,-1.197c-1.499,1.83 -2.983,3.66 -4.468,5.505c-2.449,0.446 -4.885,0.966 -7.306,1.469c0.13,-1.181 0.418,-3.558 0.563,-4.74c1.383,-0.345 2.781,-0.692 4.164,-1.037Z" style="fill:#a2a4a5;fill-rule:nonzero;"/><path d="M554.861,477.391c3.099,-0.332 6.225,-0.62 9.353,-0.808c1.426,0.347 2.867,0.677 4.323,0.995c-3.69,0.345 -8.114,0.274 -10.03,4.107c-2.723,0.303 -5.418,0.734 -8.112,1.211c1.484,-1.845 2.968,-3.675 4.467,-5.505Z" style="fill:#bdc0c2;fill-rule:nonzero;"/><path d="M514.732,478.644c5.13,1.225 9.784,3.789 14.338,6.383c1.787,-0.734 3.588,-1.441 5.389,-2.176c0.173,1.124 0.519,3.387 0.692,4.51c-2.551,1.21 -5.043,2.55 -7.507,3.934l-0.836,0.518c-0.173,-1.195 -0.533,-3.587 -0.706,-4.784l-0.187,-0.936c-2.781,-1.397 -5.663,-2.55 -8.473,-3.848c-0.922,-1.195 -1.816,-2.407 -2.709,-3.602Z" style="fill:#727373;fill-rule:nonzero;"/><path d="M558.507,481.684c1.917,-3.833 6.341,-3.761 10.03,-4.107c7.622,0.85 14.885,3.314 22.205,5.475c0.345,0.246 1.067,0.721 1.426,0.966c0.576,0.389 1.758,1.197 2.335,1.586c-4.986,1.858 -10.045,0.274 -14.843,-1.311c-6.859,-1.975 -14.049,-2.349 -21.153,-2.608Z" style="fill:#d6dadd;fill-rule:nonzero;"/><path d="M604.388,479.262l0.721,-0.993c1.671,3.299 3.257,6.671 3.919,10.346c-2.45,-2.104 -5.924,-6.168 -4.641,-9.353Z" style="fill:#686b6f;fill-rule:nonzero;"/><path d="M676.466,478.384l1.211,-0.432c2.348,0.734 4.654,1.656 6.816,2.867c0.432,0.389 1.296,1.167 1.715,1.542l0.865,2.55c-2.623,0.404 -4.914,-0.879 -7.119,-2.047c-1.296,0.951 -2.579,1.917 -3.89,2.825l-1.485,0.692c0.072,-1.384 0.216,-4.15 0.288,-5.534c0.404,-0.619 1.211,-1.843 1.599,-2.464Z" style="fill:#4e6dae;fill-rule:nonzero;"/><path d="M422.553,487.924c2.032,-2.838 4.035,-5.706 6.34,-8.329c3.833,3.445 7.767,6.831 12.306,9.324c-1.844,2.638 -3.689,5.274 -5.735,7.752c-5.548,0.433 -8.502,-6.095 -13.142,-8.43l0.231,-0.317Z" style="fill:#5f88b2;fill-rule:nonzero;"/><path d="M428.892,479.595c2.176,0.189 4.352,0.404 6.542,0.664c3.213,1.484 6.311,3.184 9.366,4.971c6.643,4.121 13.905,7.017 21.067,10.071c-1.729,0.534 -3.458,1.096 -5.159,1.701c-2.378,-0.663 -4.755,-1.325 -7.075,-2.148c-4.265,-1.715 -8.401,-3.747 -12.436,-5.937c-4.539,-2.493 -8.473,-5.878 -12.306,-9.324Z" style="fill:#3d4d5b;fill-rule:nonzero;"/><path d="M621.278,488.341c2.42,-2.162 3.156,-5.375 3.947,-8.358c4.496,0.922 8.387,3.372 9.986,7.91c2.421,-2.233 1.167,-5.23 0.49,-7.925c2.522,2.003 5.087,3.934 7.651,5.894c-5.289,9.322 -13.098,16.902 -18.2,26.297c-0.576,0.547 -1.729,1.628 -2.291,2.176c-0.36,-0.576 -1.081,-1.729 -1.426,-2.306c2.998,-0.663 2.68,-3.644 0.707,-5.273c-0.677,-1.355 -1.369,-2.695 -2.392,-3.805c-0.347,-0.533 -1.039,-1.57 -1.384,-2.104c0.404,-0.936 1.195,-2.796 1.599,-3.732c0.216,-1.599 0.375,-3.198 0.461,-4.799c0.677,-0.533 2.032,-1.614 2.709,-2.161c-0.461,-0.446 -1.383,-1.369 -1.858,-1.816Zm3.011,2.276c4.769,0.029 -2.047,4.395 0,0Zm-3.804,9.713c4.309,-2.249 0.303,4.74 0,0Zm4.035,6.297c0.734,0.764 0.734,0.764 0,0Z" style="fill:#3e4d5c;fill-rule:nonzero;"/><path d="M648.844,480.95c1.08,1.124 2.161,2.247 3.257,3.385l-1.226,1.715c-3.516,4.857 -6.426,10.146 -10.158,14.857c-1.903,2.566 -3.732,5.202 -5.289,8.013c3.861,1.325 10.201,-0.475 11.426,4.625c-0.907,-0.432 -2.737,-1.311 -3.644,-1.757c-1.298,1.282 -2.594,2.579 -3.862,3.89c0.951,-0.331 2.853,-0.993 3.804,-1.325c0.015,0.475 0.044,1.426 0.058,1.902c-3.271,0.044 -4.554,1.399 -3.833,4.035c0.979,0.187 2.939,0.547 3.933,0.734c-0.705,2.739 -0.562,6.197 -3.429,7.739c0.591,-1.83 1.138,-3.675 1.643,-5.534c0.749,-3.415 -4.294,-2.607 -4.309,0.015c-0.778,2.003 -1.426,4.05 -2.089,6.096c1.283,0.013 3.862,0.057 5.144,0.072c-0.518,2.42 -1.067,4.885 -0.591,7.378l-0.389,0.345c-0.604,0.562 -1.801,1.687 -2.392,2.249c0.073,0.634 0.231,1.917 0.317,2.55l-0.793,-0.865c-0.244,-0.274 -0.734,-0.808 -0.979,-1.081c-0.275,0.044 -0.822,0.145 -1.096,0.187c-0.835,0.145 -2.508,0.433 -3.343,0.591c-0.274,-3.126 -0.576,-6.253 -1.167,-9.322c4.828,2.781 3.227,-5.591 -0.259,-1.312l-1.037,0.894c0.389,-2.075 0.792,-4.136 1.225,-6.196c1.369,0.951 2.767,1.917 4.15,2.882c0.114,-0.519 0.36,-1.557 0.475,-2.076c-2.825,-1.282 -5.606,-2.666 -8.402,-3.976c0.389,-0.98 1.182,-2.954 1.571,-3.949c-1.628,-1.052 -3.328,-2.003 -4.698,-3.372c0.562,-0.547 1.715,-1.628 2.291,-2.176c9.496,-9.006 17.278,-19.828 23.69,-31.211Zm-18.532,38.387c0.736,0.793 0.736,0.793 0,0Zm4.525,11.427c-1.974,1.383 0.043,5.115 2.219,3.53c2.233,-1.355 -0.029,-5.274 -2.219,-3.53Z" style="fill:#323b45;fill-rule:nonzero;"/><path d="M671.22,486.957c1.138,-2.089 2.377,-4.107 3.646,-6.109c-0.072,1.383 -0.216,4.15 -0.288,5.533l1.484,-0.692c-1.037,1.254 -2.06,2.537 -3.04,3.833c-0.446,-0.634 -1.355,-1.931 -1.801,-2.565Z" style="fill:#849cc2;fill-rule:nonzero;"/><path d="M517.441,482.246c2.81,1.298 5.692,2.45 8.473,3.848l0.187,0.936c-2.378,1.456 -4.77,2.911 -7.046,4.554c-0.403,-2.335 -0.778,-4.655 -1.182,-6.974l-0.432,-2.364Z" style="fill:#101214;fill-rule:nonzero;"/><path d="M663.368,489.681c0.749,-2.118 1.368,-4.279 1.944,-6.455c1.426,1.974 3.805,2.68 5.909,3.732c0.446,0.634 1.353,1.93 1.801,2.565c-2.709,3.66 -5.058,7.565 -7.651,11.297c-0.116,-1.599 -0.216,-3.185 -0.303,-4.769c-1.744,-0.26 -3.488,-0.505 -5.217,-0.749c0.793,-1.341 1.6,-2.682 2.421,-4.007c0.274,-0.402 0.821,-1.21 1.096,-1.614Z" style="fill:#577797;fill-rule:nonzero;"/><path d="M676.061,485.689c1.311,-0.908 2.594,-1.874 3.89,-2.825c2.205,1.167 4.496,2.45 7.119,2.047c0.389,1.283 0.806,2.55 1.225,3.833c-2.306,4.698 -4.971,9.193 -7.796,13.603c-2.579,-1.456 -5.144,-2.925 -7.681,-4.453c2.608,-3.703 4.51,-7.824 6.153,-12.017c-0.72,-0.044 -2.176,-0.145 -2.91,-0.187Z" style="fill:#5f89b3;fill-rule:nonzero;"/><path d="M460.507,484.322c0.418,0.015 1.254,0.057 1.671,0.072c2.104,0.995 4.136,2.133 6.225,3.17c2.925,1.21 5.98,2.089 9.049,2.897c-2.219,1.267 -4.438,2.55 -6.643,3.861c-5.216,-1.254 -10.101,-3.516 -14.885,-5.878c1.499,-1.399 3.012,-2.796 4.582,-4.121Z" style="fill:#465b6f;fill-rule:nonzero;"/><path d="M535.149,487.362c2.594,-1.138 5.259,-2.075 7.94,-2.996c0.187,0.705 0.562,2.118 0.764,2.838c-2.306,0.461 -3.861,1.816 -3.962,4.208c1.325,1.225 2.636,2.464 3.949,3.717c-4.079,1.6 -8.142,3.271 -11.831,5.663c-1.455,-3.17 -2.911,-6.326 -4.366,-9.496c2.464,-1.383 4.957,-2.723 7.507,-3.934Z" style="fill:#d6d8db;fill-rule:nonzero;"/><path d="M676.063,485.69c0.734,0.044 2.19,0.145 2.911,0.187c-1.643,4.193 -3.545,8.314 -6.153,12.018c-2.68,3.89 -5.36,7.824 -8.589,11.283c-2.954,2.983 -5.965,5.908 -8.731,9.049c2.434,1.298 5.014,2.263 7.651,3.069c-6.673,4.784 -12.839,10.304 -19.771,14.728l-1.109,0.23c-0.072,-1.153 -0.13,-2.306 -0.173,-3.444c1.195,-1.859 2.276,-3.775 3.328,-5.72c1.484,0.705 2.983,1.369 4.51,1.988c0.259,-1.167 0.806,-3.516 1.067,-4.698c-1.34,0.101 -4.02,0.317 -5.36,0.418c0.402,-1.024 1.181,-3.084 1.584,-4.107l0.894,1.21c2.767,4.841 5.965,-3.055 0.547,-1.124c4.525,-7.422 12.277,-12.334 16.701,-19.958c2.594,-3.732 4.942,-7.637 7.651,-11.297c0.979,-1.296 2.003,-2.579 3.04,-3.833Zm-29.453,45.233c0.863,0.734 0.863,0.734 0,0Z" style="fill:#3f505f;fill-rule:nonzero;"/><path d="M519.054,491.583c2.277,-1.643 4.669,-3.099 7.046,-4.554c0.173,1.197 0.533,3.589 0.706,4.784c-3.113,1.931 -5.807,4.396 -8.444,6.902l-0.403,-0.792c-0.591,-1.298 -1.153,-2.579 -1.715,-3.877c0.908,-0.85 1.844,-1.685 2.81,-2.464Z" style="fill:#606061;fill-rule:nonzero;"/><path d="M539.89,491.41c0.101,-2.392 1.658,-3.747 3.963,-4.208c0.519,0.274 1.571,0.835 2.104,1.109c0.202,2.45 -0.461,4.626 -2.003,6.572c18.53,-6.067 40.592,-2.061 54.54,11.931c0.13,-2.348 0.26,-4.712 0.808,-7.003c2.363,-1.21 3.949,1.312 5.75,2.392c-0.446,0.966 -0.879,1.946 -1.325,2.911c-1.687,0.576 -3.328,1.197 -4.986,1.83c9.122,7.94 14.972,19.41 16.701,31.342c0.072,0.475 0.215,1.426 0.288,1.902c-3.185,6.902 -1.441,14.712 -3.502,21.888c-0.418,-0.417 -1.254,-1.268 -1.671,-1.685c3.558,-13.849 1.412,-29.267 -6.744,-41.126c-8.487,-12.867 -23.128,-21.572 -38.547,-22.551c-22.018,-1.946 -43.978,12.709 -51.154,33.517c-2.839,7.191 -3.141,15 -2.954,22.637c-0.548,0.231 -1.657,0.692 -2.219,0.908c-0.058,-0.707 -0.187,-2.148 -0.245,-2.867c-0.677,-13.906 3.747,-27.595 11.686,-38.936c1.268,-1.801 2.637,-3.516 4.121,-5.144c2.363,-2.161 4.856,-4.208 7.507,-6.038c3.689,-2.392 7.753,-4.063 11.83,-5.663c-1.311,-1.254 -2.622,-2.493 -3.949,-3.717Z" style="fill:#232020;fill-rule:nonzero;"/><path d="M616.57,489.777c-2.472,1.489 -2.616,-2.717 -0.317,-2.919l1.055,0.809c-0.187,0.521 -0.563,1.591 -0.737,2.11Z" style="fill:#b0a2b3;fill-rule:nonzero;"/><path d="M477.452,490.46c2.392,0.635 4.798,1.283 7.162,2.061c1.038,0.317 3.127,0.966 4.179,1.296c0.648,0.174 1.96,0.519 2.608,0.692c2.234,2.537 4.51,5.058 6.7,7.637c-2.579,-0.187 -5.13,-0.49 -7.623,-1.166c-5.418,-4.741 -13.142,-4.453 -19.669,-6.658c2.205,-1.311 4.424,-2.594 6.643,-3.862Z" style="fill:#3c4a58;fill-rule:nonzero;"/><path d="M624.289,490.618c4.785,0.029 -2.052,4.408 0,0Z" style="fill:#6f6e7e;fill-rule:nonzero;"/><path d="M422.869,491.987c0.737,0.752 0.737,0.752 0,0Z" style="fill:#383738;fill-rule:nonzero;"/><path d="M470.609,610.48c0.015,0.029 0.06,0.089 0.075,0.118l-0.075,-0.118Z" style="fill:#383738;fill-rule:nonzero;"/><path d="M526.807,491.814l0.836,-0.519c1.455,3.17 2.911,6.326 4.366,9.496c-2.652,1.83 -5.144,3.876 -7.508,6.038c-2.234,-2.551 -4.409,-5.188 -6.139,-8.112c2.637,-2.508 5.332,-4.973 8.444,-6.902Z" style="fill:#afafb0;fill-rule:nonzero;"/><path d="M613.005,491.584c2.486,-0.983 4.091,2.587 1.272,3.339l-1.258,-0.607c0,-0.679 -0.015,-2.052 -0.015,-2.732Z" style="fill:#efcadb;fill-rule:nonzero;"/><path d="M496.388,493.485c0.737,0.752 0.737,0.752 0,0Z" style="fill:#bed2ea;fill-rule:nonzero;"/><path d="M425.837,494.955c0.81,0.709 0.81,0.709 0,0Z" style="fill:#595759;fill-rule:nonzero;"/><path d="M511.301,498.803c1.686,-1.542 3.285,-3.17 4.943,-4.755c0.562,1.296 1.124,2.579 1.715,3.876c-1.787,2.032 -3.574,4.064 -5.375,6.082c-1.009,1.109 -2.003,2.234 -2.954,3.4c-0.807,-0.345 -2.406,-1.052 -3.213,-1.397c0.115,-1.239 0.259,-2.449 0.375,-3.675c1.556,-1.109 3.04,-2.306 4.51,-3.53Z" style="fill:#464645;fill-rule:nonzero;"/><path d="M652.791,504.74c2.737,-2.838 5.043,-6.052 7.06,-9.439c1.729,0.246 3.473,0.49 5.217,0.75c0.086,1.584 0.187,3.17 0.301,4.769c-4.423,7.622 -12.175,12.536 -16.7,19.957l-0.547,1.125l-0.894,-1.211c0.547,-1.555 1.182,-3.069 1.83,-4.567c3.185,-2.767 6.21,-5.764 8.371,-9.425c-1.555,-0.663 -3.097,-1.311 -4.639,-1.959Z" style="fill:#4e677f;fill-rule:nonzero;"/><path d="M428.747,497.852c0.781,0.752 0.781,0.752 0,0Z" style="fill:#5d5b5d;fill-rule:nonzero;"/><path d="M610.066,497.002c1.83,1.715 3.589,3.516 5.333,5.331c1.225,1.586 2.579,2.853 4.351,0.62c1.023,1.109 1.715,2.449 2.392,3.804c-0.562,0.246 -1.715,0.749 -2.291,1.009c-1.369,1.252 1.153,2.954 1.586,4.265c0.345,0.576 1.065,1.729 1.426,2.306c1.368,1.369 3.069,2.32 4.698,3.372c-0.389,0.993 -1.182,2.968 -1.571,3.947c2.796,1.312 5.577,2.695 8.401,3.978c-0.116,0.519 -0.36,1.557 -0.475,2.075c-1.383,-0.966 -2.781,-1.931 -4.149,-2.882c-0.433,2.061 -0.837,4.121 -1.226,6.197c-1.138,0.993 -2.291,1.974 -3.429,2.968c-1.599,-5.072 -2.867,-10.26 -4.885,-15.189c-2.796,-6.816 -7.435,-12.608 -11.672,-18.56c0.375,-0.806 1.138,-2.434 1.513,-3.242Z" style="fill:#d1d2d6;fill-rule:nonzero;"/><path d="M664.233,509.178c3.227,-3.458 5.908,-7.392 8.588,-11.283c2.537,1.527 5.101,2.996 7.681,4.452c-2.853,3.789 -5.562,7.782 -9.166,10.923c-2.42,-1.283 -4.769,-2.68 -7.103,-4.092Z" style="fill:#54728e;fill-rule:nonzero;"/><path d="M512.583,504.005c1.801,-2.017 3.588,-4.05 5.375,-6.08l0.403,0.792c1.729,2.926 3.905,5.562 6.139,8.114c-1.484,1.628 -2.853,3.343 -4.121,5.144c-0.922,-0.231 -2.781,-0.692 -3.703,-0.923c-1.369,-2.348 -2.738,-4.711 -4.092,-7.046Z" style="fill:#939293;fill-rule:nonzero;"/><path d="M485.35,596.229c0.519,-2.278 1.052,-4.54 1.556,-6.816c-0.49,5.562 0.332,11.138 1.801,16.498l0.259,0.793l-1.744,1.11c-5.894,-0.418 -6.787,-6.471 -7.09,-11.196c1.729,-0.13 3.473,-0.26 5.216,-0.389Z" style="fill:#939293;fill-rule:nonzero;"/><path d="M470.349,647.555c0.853,0.823 0.853,0.823 0,0Z" style="fill:#939293;fill-rule:nonzero;"/><path d="M640.715,500.907c2.565,-0.691 5.101,-1.527 7.738,-1.944c1.542,0.907 2.998,1.959 4.382,3.112c-2.781,2.335 -5.606,5.015 -5.39,8.964c-0.202,0.835 -0.404,1.671 -0.591,2.506c-1.225,-5.1 -7.565,-3.299 -11.427,-4.625c1.557,-2.81 3.387,-5.447 5.289,-8.013Z" style="fill:#394754;fill-rule:nonzero;"/><path d="M431.371,500.216c2.038,-0.636 2.689,0.072 1.966,2.11c-2.038,0.636 -2.703,-0.073 -1.966,-2.11Z" style="fill:#454445;fill-rule:nonzero;"/><path d="M620.485,500.332c4.323,-2.254 0.304,4.756 0,0Z" style="fill:#ba9baf;fill-rule:nonzero;"/><path d="M478.533,501.657c5.966,1.138 11.917,2.521 18.012,2.895c-6.456,1.614 -12.162,7.047 -19.179,4.597c-1.182,-0.173 -3.559,-0.533 -4.755,-0.705c1.7,-2.493 3.674,-4.784 5.922,-6.787Z" style="fill:#384653;fill-rule:nonzero;"/><path d="M449.873,503.357c2.594,1.067 5.159,2.19 7.781,3.227c0.086,0.029 0.259,0.101 0.346,0.145l-0.101,-0.101c4.611,0.49 9.193,1.182 13.79,1.715c-0.764,0.116 -2.277,0.345 -3.04,0.446c-0.49,1.99 -0.908,4.007 -1.34,6.023c-3.026,1.11 -6.052,2.292 -8.977,3.675c-2.392,1.153 -4.741,2.392 -7.104,3.617c-2.565,-0.619 -5.159,-1.023 -7.767,-1.355l-0.245,-0.043l-0.014,0.015c-3.761,1.931 -7.263,4.336 -11.038,6.24c-0.144,-0.015 -0.432,-0.044 -0.562,-0.058c-0.634,-0.086 -1.888,-0.288 -2.507,-0.389c-0.893,0.749 -1.787,1.485 -2.666,2.234l-0.692,-1.557l0.677,-0.331c-0.231,-0.044 -0.706,-0.159 -0.937,-0.216c-0.504,-0.044 -1.499,-0.13 -1.989,-0.173c-1.11,0.158 -3.386,4.063 -3.43,0.648c0.043,-0.072 0.101,-0.231 0.13,-0.317c0.605,-0.475 1.816,-1.426 2.421,-1.902c2.075,-1.369 4.525,-3.458 6.398,-0.518c1.787,-0.404 3.559,-0.822 5.36,-1.211c-0.115,-0.907 -0.346,-2.709 -0.461,-3.617c0.965,0.591 2.882,1.758 3.833,2.348c2.046,-1.829 3.991,-3.775 5.807,-5.836c0.864,0.923 1.729,1.845 2.608,2.767l0.98,-0.129c-0.865,-1.485 -1.7,-2.968 -2.536,-4.453c2.262,-1.225 3.934,0.793 5.634,1.946c-0.836,-1.557 -1.599,-3.141 -2.291,-4.755c1.945,-0.116 3.732,0.547 5.332,1.974c-1.239,-2.19 -3.285,-4.683 -0.072,-6.269c-0.231,-0.417 -0.721,-1.239 -0.966,-1.656c-0.965,1.614 -2.32,2.824 -4.035,3.602c-0.49,-2.19 0.303,-4.077 1.671,-5.735Zm4.698,5.894c0.778,0.705 0.778,0.705 0,0Zm5.807,-0.174c0.778,0.721 0.778,0.721 0,0Zm-2.896,2.867c0.735,0.764 0.735,0.764 0,0Zm-2.968,2.638c4.352,-2.162 0.13,4.769 0,0Z" style="fill:#454546;fill-rule:nonzero;"/><path d="M496.545,504.552c3.329,-0.013 6.744,0.145 9.871,1.456c0.807,0.347 2.406,1.052 3.213,1.399c-2.176,2.882 -4.121,5.922 -5.908,9.049c-0.202,-2.709 -0.504,-5.418 -0.778,-8.112c-4.755,3.097 -10.678,2.882 -15.966,4.452c0.403,0.404 1.225,1.197 1.643,1.586l-0.101,1.355c-3.271,0.648 -6.542,1.282 -9.827,1.873c-0.158,-2.003 -0.375,-3.991 -0.692,-5.979c-0.187,-0.837 -0.403,-1.658 -0.634,-2.48c7.018,2.45 12.724,-2.982 19.179,-4.597Z" style="fill:#2f3841;fill-rule:nonzero;"/><path d="M509.63,507.407c0.951,-1.167 1.945,-2.291 2.954,-3.401c1.354,2.335 2.723,4.698 4.092,7.047c0.922,0.23 2.781,0.692 3.703,0.922c-7.94,11.341 -12.364,25.03 -11.686,38.936c-0.634,-5.347 -0.461,-10.836 -2.161,-16.009c-0.072,-0.274 -0.231,-0.835 -0.303,-1.11c2.435,0.101 2.421,-2.737 3.257,-4.279c1.499,-4.813 3.919,-9.251 6.484,-13.559c-4.179,0.129 -8.343,0.417 -12.479,1.08l0.231,-0.576c1.787,-3.126 3.732,-6.167 5.908,-9.049Z" style="fill:#7b7a7a;fill-rule:nonzero;"/><path d="M647.444,511.038c1.658,-2.205 3.458,-4.295 5.346,-6.298c1.542,0.649 3.084,1.298 4.641,1.96c-2.161,3.66 -5.188,6.657 -8.373,9.423c-0.518,-0.489 -1.555,-1.484 -2.075,-1.988c-0.606,2.133 -1.845,3.891 -3.646,5.159l-0.129,-3.04c-0.015,-0.475 -0.044,-1.426 -0.058,-1.902c-0.951,0.332 -2.853,0.995 -3.804,1.325c1.268,-1.311 2.565,-2.608 3.862,-3.89c0.907,0.446 2.737,1.325 3.644,1.757c0.187,-0.835 0.389,-1.671 0.591,-2.506Z" style="fill:#45596c;fill-rule:nonzero;"/><path d="M437.308,506.324c0.737,0.781 0.737,0.781 0,0Z" style="fill:#575556;fill-rule:nonzero;"/><path d="M624.52,506.628c0.737,0.766 0.737,0.766 0,0Z" style="fill:#af8ea2;fill-rule:nonzero;"/><path d="M486.979,512.795c5.288,-1.57 11.211,-1.355 15.966,-4.452c0.274,2.695 0.576,5.403 0.778,8.112l-0.231,0.576c-0.908,2.205 -1.816,4.409 -2.695,6.643c-1.038,2.794 -1.917,5.649 -2.824,8.501c0.504,0.187 1.484,0.562 1.989,0.749c0.677,-0.461 2.017,-1.397 2.695,-1.859c1.268,0.793 2.493,1.643 3.574,2.724c0.072,0.274 0.231,0.835 0.303,1.109c-1.009,1.125 -1.974,2.32 -2.954,3.473c-1.599,-1.744 -3.761,-2.68 -5.894,-3.602c-0.317,-2.926 -1.297,-5.678 -2.421,-8.373c0.432,-2.205 0.922,-4.38 1.412,-6.57c-0.418,-0.475 -0.836,-0.966 -1.254,-1.441c0.965,-1.225 4.164,-1.672 3.156,-3.675c-3.271,-0.806 -6.657,-0.303 -9.957,-0.331c-0.418,-0.389 -1.239,-1.182 -1.643,-1.586Z" style="fill:#232224;fill-rule:nonzero;"/><path d="M440.291,509.249c0.723,0.781 0.723,0.781 0,0Z" style="fill:#4f4e4f;fill-rule:nonzero;"/><path d="M445.881,509.234c0.838,0.68 0.838,0.68 0,0Z" style="fill:#535152;fill-rule:nonzero;"/><path d="M468.649,508.789c0.764,-0.101 2.277,-0.331 3.041,-0.446l0.922,0.101c1.196,0.173 3.574,0.533 4.755,0.705c0.231,0.822 0.447,1.643 0.634,2.478c-3.242,0.851 -6.47,1.744 -9.64,2.81l-1.052,0.375c0.432,-2.017 0.85,-4.035 1.34,-6.023Z" style="fill:#0d1012;fill-rule:nonzero;"/><path d="M655.5,518.228c2.767,-3.141 5.779,-6.067 8.733,-9.049c2.334,1.412 4.683,2.809 7.104,4.092c-2.537,2.867 -5.347,5.461 -8.185,8.026c-2.636,-0.806 -5.216,-1.772 -7.651,-3.069Z" style="fill:#485e73;fill-rule:nonzero;"/><path d="M443.044,512.262c0.737,0.766 0.737,0.766 0,0Z" style="fill:#4f4d4e;fill-rule:nonzero;"/><path d="M468.361,514.439c3.17,-1.067 6.398,-1.96 9.64,-2.81c0.317,1.988 0.533,3.978 0.692,5.981l-0.418,0.835c-2.19,0.966 -4.438,1.845 -6.657,2.709c-0.62,-0.347 -1.859,-1.039 -2.478,-1.384c-0.288,-1.786 -0.548,-3.559 -0.778,-5.331Z" style="fill:#3a4856;fill-rule:nonzero;"/><path d="M422.94,515c0.741,0.726 0.741,0.726 0,0Z" style="fill:#313031;fill-rule:nonzero;"/><path d="M440.203,529.407c0.723,0.752 0.723,0.752 0,0Z" style="fill:#313031;fill-rule:nonzero;"/><path d="M440.262,515.057c0.795,0.723 0.795,0.723 0,0Z" style="fill:#525153;fill-rule:nonzero;"/><path d="M454.511,514.584c4.365,-2.169 0.13,4.784 0,0Z" style="fill:#1a191a;fill-rule:nonzero;"/><path d="M467.308,514.811l1.052,-0.375c0.231,1.773 0.49,3.545 0.778,5.333c0.62,0.345 1.859,1.037 2.478,1.383c-3.055,1.298 -5.994,2.825 -8.876,4.453c-1.47,-2.377 -2.94,-4.756 -4.409,-7.119c2.925,-1.383 5.951,-2.565 8.977,-3.675Z" style="fill:#465a6e;fill-rule:nonzero;"/><path d="M646.983,514.135c0.519,0.505 1.557,1.499 2.076,1.99c-0.649,1.498 -1.283,3.011 -1.83,4.567c-0.404,1.023 -1.182,3.084 -1.586,4.107c1.34,-0.101 4.02,-0.317 5.36,-0.418c-0.259,1.182 -0.806,3.53 -1.067,4.698c-1.527,-0.619 -3.026,-1.282 -4.509,-1.988c-1.052,1.946 -2.133,3.862 -3.33,5.72c0.044,1.138 0.101,2.291 0.174,3.444c-0.649,0.13 -1.946,0.389 -2.594,0.534c-0.475,-2.493 0.072,-4.957 0.59,-7.378c-1.282,-0.015 -3.861,-0.058 -5.144,-0.072c0.663,-2.047 1.311,-4.093 2.089,-6.096c0.015,-2.622 5.059,-3.429 4.309,-0.015c-0.503,1.859 -1.052,3.704 -1.643,5.534c2.869,-1.542 2.724,-5 3.431,-7.739c-0.995,-0.187 -2.954,-0.547 -3.934,-0.734c-0.721,-2.636 0.562,-3.991 3.833,-4.035l0.129,3.04c1.802,-1.267 3.041,-3.026 3.646,-5.159Z" style="fill:#cb8dac;fill-rule:nonzero;"/><path d="M478.692,517.607c3.285,-0.59 6.556,-1.225 9.827,-1.873c-0.692,1.47 -1.441,2.911 -2.19,4.351c-3.631,1.197 -7.277,2.379 -10.851,3.733c0.951,-1.788 1.873,-3.589 2.795,-5.375l0.418,-0.837Z" style="fill:#596d81;fill-rule:nonzero;"/><path d="M503.491,517.032c4.136,-0.663 8.3,-0.951 12.479,-1.08c-2.565,4.309 -4.986,8.746 -6.484,13.559c-3.113,-1.599 -6.067,-3.516 -8.689,-5.836c0.879,-2.234 1.787,-4.439 2.695,-6.643Z" style="fill:#f2f2f3;fill-rule:nonzero;"/><path d="M414.381,518.025c0.708,0.766 0.708,0.766 0,0Z" style="fill:#373637;fill-rule:nonzero;"/><path d="M437.322,532.291c0.726,0.741 0.726,0.741 0,0Z" style="fill:#373637;fill-rule:nonzero;"/><path d="M432.281,557.127c-1.344,-1.778 -0.853,-2.385 1.474,-1.822c-0.361,0.464 -1.113,1.373 -1.474,1.822Z" style="fill:#373637;fill-rule:nonzero;"/><path d="M420.103,517.997c0.766,0.766 0.766,0.766 0,0Z" style="fill:#575657;fill-rule:nonzero;"/><path d="M425.592,517.71c4.394,-2.11 0.072,4.799 0,0Z" style="fill:#3d3d3e;fill-rule:nonzero;"/><path d="M455.998,613.115c0.781,0.724 0.781,0.724 0,0Z" style="fill:#3d3d3e;fill-rule:nonzero;"/><path d="M437.322,517.882c0.81,0.724 0.81,0.724 0,0Z" style="fill:#5d5c5d;fill-rule:nonzero;"/><path d="M451.226,522.104c2.363,-1.225 4.712,-2.464 7.104,-3.617c1.47,2.363 2.94,4.74 4.409,7.118c-2.868,1.658 -5.677,3.401 -8.314,5.404c-0.922,-2.623 -1.787,-5.259 -2.767,-7.853l-0.432,-1.052Z" style="fill:#516b85;fill-rule:nonzero;"/><path d="M630.312,519.336c0.737,0.796 0.737,0.796 0,0Z" style="fill:#a77e96;fill-rule:nonzero;"/><path d="M411.256,520.662c4.799,0.216 -2.212,4.322 0,0Z" style="fill:#363637;fill-rule:nonzero;"/><path d="M417.162,520.893c0.838,0.723 0.838,0.723 0,0Z" style="fill:#717071;fill-rule:nonzero;"/><path d="M625.497,559.498c0.936,-4.338 0.692,-10.447 6.109,-11.672c0.145,0.648 0.433,1.959 0.576,2.607c0.058,4.611 -2.377,8.762 -2.723,13.329c-0.332,1.384 0.057,3.328 -1.744,3.717c-5.893,2.349 -7.666,9.107 -11.989,13.243c-1.513,-3.099 -3.126,-6.139 -4.726,-9.179c-0.692,0.648 -2.104,1.974 -2.796,2.622c1.181,-3.962 2.91,-7.709 4.841,-11.354c-0.143,2.219 -0.216,4.453 -0.288,6.7c3.084,-0.116 6.326,0.446 9.294,-0.663c2.032,-2.709 2.796,-6.095 3.444,-9.351Z" style="fill:#717071;fill-rule:nonzero;"/><path d="M422.609,520.794c4.308,-2.284 0.246,4.842 0,0Z" style="fill:#5f5d5f;fill-rule:nonzero;"/><path d="M428.862,520.735c0.679,0.853 0.679,0.853 0,0Z" style="fill:#666465;fill-rule:nonzero;"/><path d="M432.162,526.958c3.775,-1.902 7.277,-4.309 11.038,-6.238c0.072,0 0.187,0.013 0.259,0.028c2.608,0.332 5.202,0.736 7.767,1.355l0.432,1.052c-2.017,1.081 -4.035,2.176 -6.038,3.286c-0.605,-0.116 -1.816,-0.332 -2.421,-0.433c0.144,0.519 0.447,1.571 0.605,2.104l0.331,0.534c-0.187,2.666 -0.49,5.346 -0.793,8.011c-2.147,0.576 -4.28,1.153 -6.427,1.744c2.032,1.283 4.092,2.522 5.951,4.064c0.447,2.276 0.475,4.61 0.634,6.93c0.245,-0.288 0.749,-0.835 0.994,-1.109c0.648,-0.707 1.974,-2.089 2.623,-2.796c3.069,0.951 6.124,2.032 8.977,3.545l0.216,0.173c0.677,0.519 2.032,1.542 2.709,2.061c-0.49,0.402 -1.455,1.195 -1.945,1.599c2.032,0.072 4.078,0.143 6.11,0.36c-1.081,0.98 -2.161,1.975 -3.228,2.968c0.879,0.159 2.637,0.475 3.516,0.635c0.014,0.503 0.043,1.498 0.058,1.988c3.804,4.279 8.415,8.501 9.121,14.525c0.447,0.274 1.311,0.85 1.744,1.138c1.556,2.335 3.516,4.352 5.764,6.023c-0.778,5.347 -0.115,10.75 -0.029,16.11c-1.427,0.519 -2.853,1.023 -4.28,1.542c-1.801,-1.34 -3.574,-2.68 -5.332,-4.035c0.058,-2.535 -0.648,-4.942 -1.715,-7.191c-1.671,0.433 -3.343,0.879 -5,1.325c0.533,-1.599 1.066,-3.184 1.614,-4.769c-1.528,-1.902 -3.257,-3.631 -5.072,-5.245c0.533,-0.231 1.585,-0.692 2.118,-0.936c-3.127,-4.683 -6.628,-9.136 -10.937,-12.797c1.196,-2.104 3.358,-4.625 1.268,-6.959c-2.363,-0.044 -4.597,0.835 -6.83,1.456c-0.36,0.793 -1.095,2.363 -1.47,3.156c-0.187,-1.037 -0.562,-3.126 -0.749,-4.165c-1.945,0.073 -3.891,0.145 -5.822,0.231c-0.115,-0.749 -0.346,-2.262 -0.461,-3.012c-0.303,0.231 -0.908,0.692 -1.21,0.923c-0.62,-0.202 -1.873,-0.62 -2.507,-0.835c-2.32,-0.563 -2.81,0.043 -1.47,1.816c-1.182,0.101 -2.378,0.187 -3.545,0.244c-1.052,-1.225 -2.104,-2.436 -3.098,-3.675c0.519,-0.158 1.585,-0.475 2.118,-0.634l-0.014,-1.067c0.274,-1.917 -0.605,-3.112 -2.651,-3.602l1.21,-0.562c0.346,-0.275 1.037,-0.837 1.383,-1.11c0.749,-2.781 -2.752,-4.121 -4.193,-6.038c-0.749,1.513 -1.902,2.565 -3.444,3.128c-1.311,-2.695 -3.804,-4.439 -5.951,-6.399c-0.159,0.707 -0.476,2.118 -0.634,2.825l0.447,0.879c0.029,0.475 0.101,1.412 0.13,1.873c-0.389,0.707 -1.167,2.118 -1.556,2.825c-0.231,-0.908 -0.706,-2.695 -0.951,-3.589c-0.764,0.101 -2.32,0.303 -3.098,0.404c-0.014,-0.375 -0.058,-1.11 -0.086,-1.47c0.965,0.086 2.896,0.288 3.862,0.375c-1.527,-2.276 -3.069,-4.538 -4.553,-6.829c0.965,0.23 2.911,0.663 3.876,0.893c-0.317,-1.037 -0.965,-3.112 -1.282,-4.149c0.677,0 2.061,0.015 2.752,0.015c1.412,-3.891 4.813,-6.456 7.868,-9.006l1.528,0.777l0.216,1.672c-0.605,0.475 -1.816,1.426 -2.421,1.902l-0.187,0.145l0.058,0.173c0.043,3.415 2.32,-0.49 3.43,-0.648c0.49,0.043 1.484,0.129 1.989,0.173l0.259,0.547l0.692,1.557c0.879,-0.749 1.772,-1.485 2.666,-2.234c0.62,0.101 1.873,0.303 2.507,0.389l0.418,0.13l0.144,-0.073Zm13.79,-3.343c0.663,0.808 0.663,0.808 0,0Zm-8.545,2.911c0.749,0.749 0.749,0.749 0,0Zm-20.347,2.954c0.778,0.793 0.778,0.793 0,0Zm17.436,-0.057c0.778,0.72 0.778,0.72 0,0Zm5.706,-0.015c0.721,0.749 0.721,0.749 0,0Zm-20.087,2.91c0.735,0.764 0.735,0.764 0,0Zm5.706,0.015c0.749,0.75 0.749,0.75 0,0Zm5.591,-0.244c4.294,-2.263 0.303,4.755 0,0Zm5.908,0.202c0.721,0.734 0.721,0.734 0,0Zm-21.024,2.521c0.418,2.753 2.651,4.237 5.043,5.217c-0.13,-1.239 -0.389,-3.747 -0.519,-5c-1.138,-0.057 -3.386,-0.158 -4.525,-0.216Zm6.6,0.49c0.778,0.692 0.778,0.692 0,0Zm5.692,-0.316c4.366,-2.133 0.115,4.769 0,0Zm-2.868,2.968c4.366,-2.091 0.072,4.769 0,0Zm2.752,2.867c4.294,-2.263 0.259,4.769 0,0Zm3.084,8.819c0.764,0.749 0.764,0.749 0,0Z" style="fill:#696768;fill-rule:nonzero;"/><path d="M648.671,520.78c5.435,-1.936 2.226,5.985 -0.549,1.128l0.549,-1.128Z" style="fill:#eb9ec1;fill-rule:nonzero;"/><path d="M519.385,536.311c1.744,-5.619 3.948,-13.847 11.067,-14.222c11.341,-0.721 24.021,-0.995 33.776,5.777c-5.388,0 -10.418,2.089 -14.726,5.202l1.729,-0.648c-1.873,2.118 -3.905,4.077 -6.095,5.864c0.389,-1.628 0.749,-3.242 1.109,-4.856c-9.092,-1.34 -18.127,0.418 -26.86,2.882Z" style="fill:#383737;fill-rule:nonzero;"/><path d="M408.302,523.574c4.799,0.145 -2.183,4.351 0,0Z" style="fill:#2c2b2c;fill-rule:nonzero;"/><path d="M414.525,523.977c0.838,0.766 0.838,0.766 0,0Z" style="fill:#807e7f;fill-rule:nonzero;"/><path d="M445.954,523.617c0.665,0.809 0.665,0.809 0,0Z" style="fill:#2a292a;fill-rule:nonzero;"/><path d="M445.621,526.442c2.003,-1.109 4.02,-2.205 6.038,-3.286c0.98,2.594 1.844,5.232 2.767,7.853c-1.326,1.47 -2.565,3.012 -3.804,4.554c-3.184,-1.096 -4.582,-4.424 -6.484,-6.917l-0.331,-0.533c0.447,-0.418 1.354,-1.254 1.816,-1.672Z" style="fill:#597a9b;fill-rule:nonzero;"/><path d="M497.971,532.177c0.908,-2.853 1.787,-5.706 2.824,-8.501c2.623,2.32 5.577,4.237 8.689,5.836c-0.836,1.542 -0.821,4.38 -3.257,4.279c-1.081,-1.08 -2.306,-1.931 -3.574,-2.723c-0.677,0.461 -2.017,1.397 -2.695,1.858c-0.504,-0.187 -1.484,-0.562 -1.989,-0.749Z" style="fill:#d0ced0;fill-rule:nonzero;"/><path d="M471.804,525.794c3.04,-0.202 6.11,-0.36 9.165,-0.562c0.072,3.4 -2.363,5.59 -5.159,7.018c-2.32,1.34 -4.683,2.579 -6.917,4.02c-0.346,-1.845 -0.663,-3.69 -0.98,-5.52c-3.184,1.902 -6.456,3.791 -10.202,4.237c4.121,-3.862 8.934,-6.888 14.093,-9.193Z" style="fill:#516c86;fill-rule:nonzero;"/><path d="M411.213,526.312c1.894,-1.821 3.151,1.879 1.749,3.051c-1.72,1.575 -3.021,-1.951 -1.749,-3.051Z" style="fill:#898789;fill-rule:nonzero;"/><path d="M433.748,555.302c0.634,0.216 1.888,0.635 2.507,0.837c-0.533,0.691 -1.599,2.06 -2.133,2.752c0.821,-0.13 2.45,-0.389 3.257,-0.519c0.677,2.118 1.427,4.237 2.536,6.183c2.277,2.723 4.698,5.346 7.032,8.026c-0.389,1.254 -0.778,2.508 -1.167,3.776c4.092,3.53 7.796,7.492 10.995,11.844c-0.807,0.835 -1.599,1.685 -2.406,2.535c0.706,0.216 2.089,0.649 2.781,0.879c3.631,5.274 8.761,9.54 11.067,15.692l-0.13,-0.086c0.058,0.086 0.187,0.246 0.245,0.317c0.576,0.734 1.715,2.205 2.277,2.939l0.072,0.116l-1.038,1.153c-0.461,0.418 -1.354,1.254 -1.816,1.672c-0.216,1.816 -0.375,3.659 -0.504,5.504c-0.605,-0.606 -1.816,-1.786 -2.406,-2.377l-0.432,-0.49l0.101,0.231c-0.663,0.562 -1.989,1.685 -2.637,2.247l-0.418,0.505l0.259,0.432l0.634,-0.143c0.548,0.606 1.614,1.83 2.147,2.449l0.288,0.375l0.346,0.303c0.634,0.635 1.902,1.902 2.536,2.522c0.043,0.072 0.144,0.216 0.202,0.288c-0.403,0.505 -1.21,1.513 -1.599,2.017c-1.499,-0.446 -4.51,-1.311 -6.009,-1.758c0.058,0.648 0.159,1.96 0.216,2.623c-0.634,-0.721 -1.888,-2.133 -2.522,-2.84c0.893,-1.023 1.772,-2.045 2.623,-3.083c-1.931,-0.938 -3.43,-2.32 -4.453,-4.165c-0.793,1.153 -1.571,2.32 -2.349,3.488c-3.905,-1.283 -1.47,-3.804 0.663,-5.39c-4.049,1.067 -3.761,-4.611 -6.845,-5.937c2.824,-0.995 4.626,-3.257 6.11,-5.692c-2.406,-3.011 -5.778,-3.573 -9.395,-2.708c0.331,-3.242 -1.873,-4.193 -4.568,-4.382c-0.548,-0.59 -1.643,-1.801 -2.19,-2.392c0.461,1.34 1.383,4.035 1.845,5.39c-2.507,-1.167 -4.539,-3.069 -6.528,-4.957c0.706,-0.62 2.104,-1.859 2.796,-2.478l0.072,0.101c0.101,-0.13 0.317,-0.389 0.418,-0.519c0.649,-0.648 1.96,-1.931 2.608,-2.579l0.375,0.389l0,-0.749c-0.014,-0.606 -0.014,-1.801 -0.029,-2.407c-1.571,-1.873 -3.271,-3.631 -4.943,-5.39c0.634,-0.533 1.917,-1.614 2.551,-2.146c-1.845,-2.205 -3.732,-4.367 -5.649,-6.485c0.418,-1.065 0.85,-2.117 1.282,-3.169c-1.455,0.446 -2.911,0.907 -4.366,1.368c0.432,-1.369 0.922,-2.695 1.383,-4.035c-3.242,0.404 -6.484,-0.143 -9.539,-1.225c-2.493,-2.622 3.329,-4.51 4.755,-6.326c0.331,-2.176 1.7,-2.998 4.078,-2.464c1.167,-0.058 2.363,-0.145 3.545,-0.246c0.36,-0.446 1.11,-1.353 1.47,-1.816Zm-2.277,5.505c4.38,-2.118 0.086,4.769 0,0Zm-2.824,2.983c4.352,-2.148 0.173,4.769 0,0Zm5.865,-0.116c4.453,-1.931 -0.115,4.769 0,0Zm-2.94,3.069c4.309,-2.234 0.216,4.769 0,0Zm5.706,-0.13c4.265,-2.291 0.303,4.784 0,0Zm2.911,2.911c4.309,-2.219 0.187,4.769 0,0Zm2.867,2.882c4.309,-2.263 0.274,4.755 0,0Zm-2.752,2.998c4.208,-2.392 0.418,4.769 0,0Zm5.634,5.649c4.323,-2.191 0.202,4.784 0,0Zm2.925,2.881c4.208,-2.405 0.432,4.756 0,0Zm-7.392,3.113c0.749,0.734 0.749,0.734 0,0Zm4.496,-0.13c4.179,-2.464 0.519,4.741 0,0Zm5.879,-0.114c4.294,-2.234 0.245,4.769 0,0Zm-8.646,2.881c4.438,-2.003 -0.029,4.771 0,0Zm-1.772,3.229c0.764,0.734 0.764,0.734 0,0Zm2.868,2.796c0.749,0.734 0.749,0.734 0,0Zm5.937,0.043c0.735,0.749 0.735,0.749 0,0Zm2.954,2.767c0.735,0.749 0.735,0.749 0,0Zm5.678,0.086c0.735,0.721 0.735,0.721 0,0Zm-2.853,2.825c0.793,0.692 0.793,0.692 0,0Zm5.764,0.072c0.764,0.734 0.764,0.734 0,0Zm-2.882,2.825c0.663,0.806 0.663,0.806 0,0Zm-2.896,2.895c0.793,0.692 0.793,0.692 0,0Zm5.778,-0.028c0.692,0.792 0.692,0.792 0,0Zm-8.617,2.982c0.764,0.721 0.764,0.721 0,0Zm5.807,-0.057c0.735,0.734 0.735,0.734 0,0Zm5.764,-0.043c0.735,0.764 0.735,0.764 0,0Zm-8.732,3.04c0.778,0.72 0.778,0.72 0,0Zm5.764,-0.072c0.807,0.691 0.807,0.691 0,0Zm-2.954,2.982c0.778,0.793 0.778,0.793 0,0Zm2.68,8.675c0.36,0.417 0.36,0.417 0,0Z" style="fill:#898789;fill-rule:nonzero;"/><path d="M659.939,562.38c1.426,-2.638 5.808,0.503 6.816,2.132c-2.247,0.534 -5.922,0.448 -6.816,-2.132Z" style="fill:#898789;fill-rule:nonzero;"/><path d="M431.601,526.901c0.13,0.015 0.419,0.044 0.564,0.058l-0.145,0.072l-0.419,-0.13Z" style="fill:#0b0b0b;fill-rule:nonzero;"/><path d="M437.407,526.527c0.752,0.752 0.752,0.752 0,0Z" style="fill:#1b1a1b;fill-rule:nonzero;"/><path d="M443.201,526.009c0.607,0.101 1.821,0.317 2.429,0.433c-0.463,0.42 -1.373,1.258 -1.821,1.677c-0.159,-0.535 -0.462,-1.591 -0.607,-2.11Z" style="fill:#3b3c3f;fill-rule:nonzero;"/><path d="M660.344,526.557c2.312,-0.809 3.15,-0.029 2.529,2.327c-2.356,0.81 -3.209,0.044 -2.529,-2.327Z" style="fill:#788897;fill-rule:nonzero;"/><path d="M408.43,529.727c0.809,0.766 0.809,0.766 0,0Z" style="fill:#737274;fill-rule:nonzero;"/><path d="M434.496,529.425c0.781,0.723 0.781,0.723 0,0Z" style="fill:#1f1e1f;fill-rule:nonzero;"/><path d="M444.136,528.646c1.902,2.493 3.3,5.821 6.484,6.916c2.176,-0.129 4.366,-0.288 6.542,-0.489c-1.369,1.887 -2.752,3.775 -4.136,5.676c0.98,2.767 1.96,5.548 3.069,8.286c-2.853,-1.513 -5.908,-2.594 -8.977,-3.545l0.475,-0.448c2.334,0.576 4.64,1.283 6.96,1.96c-0.893,-1.672 -1.772,-3.328 -2.666,-4.986c0.259,-1.037 0.764,-3.126 1.009,-4.178c-2.882,1.254 -6.239,2.031 -8.3,4.597c-0.922,1.801 -0.086,3.905 -0.101,5.85c-0.245,0.274 -0.749,0.821 -0.994,1.109c-0.159,-2.32 -0.187,-4.654 -0.634,-6.932c-1.859,-1.542 -3.919,-2.781 -5.951,-4.063c2.147,-0.591 4.28,-1.167 6.427,-1.744c0.303,-2.666 0.605,-5.346 0.793,-8.011Z" style="fill:#c4d4ec;fill-rule:nonzero;"/><path d="M629.576,530.133c3.498,-4.293 5.103,4.105 0.26,1.315l-0.26,-1.315Z" style="fill:#e798bc;fill-rule:nonzero;"/><path d="M457.711,534.987c3.747,-0.446 7.018,-2.335 10.202,-4.237c0.317,1.83 0.634,3.675 0.98,5.518c-1.556,1.096 -3.041,2.278 -4.481,3.517c-3.271,2.608 -5.865,5.908 -8.098,9.423l-0.216,-0.173c-1.11,-2.737 -2.089,-5.518 -3.069,-8.285c1.383,-1.902 2.767,-3.791 4.136,-5.678l0.548,-0.086Z" style="fill:#5d82a8;fill-rule:nonzero;"/><path d="M556.115,529.568c4.712,0.49 9.799,0.404 13.934,3.084c4.035,2.276 6.513,6.368 8.387,10.476c-0.764,1.037 -1.527,2.075 -2.291,3.097c-1.009,-9.351 -12.191,-12.204 -19.987,-10.389c-2.348,4.193 -2.824,10.49 1.384,13.718c3.978,3.055 9.309,1.57 13.891,1.397c-5.23,5.908 -16.081,3.66 -18.877,-3.646c-2.853,-5.303 0.101,-11.154 4.208,-14.741c-0.143,-0.534 -0.461,-1.614 -0.619,-2.148l-0.029,-0.85Z" style="fill:#4a4243;fill-rule:nonzero;"/><path d="M628.539,531.022l1.037,-0.893l0.259,1.311c0.591,3.069 0.894,6.196 1.167,9.324c0.835,-0.159 2.508,-0.448 3.343,-0.591c-0.49,1.584 -0.966,3.184 -1.412,4.799c-0.332,0.705 -0.995,2.132 -1.325,2.853c-5.419,1.225 -5.173,7.334 -6.109,11.672c-1.817,-1.412 -3.632,-2.838 -5.505,-4.136c-0.475,0.288 -1.441,0.879 -1.917,1.182c-0.764,0.503 -2.262,1.513 -3.026,2.017c0.288,-2.047 0.634,-4.05 0.878,-6.082c3.099,-0.562 6.643,-0.202 9.353,-2.118c1.858,-3.861 1.557,-8.3 0.936,-12.406c-0.274,-0.995 -0.835,-2.968 -1.109,-3.963c1.138,-0.993 2.291,-1.974 3.429,-2.968Z" style="fill:#9f9fa0;fill-rule:nonzero;"/><path d="M634.837,530.765c2.19,-1.744 4.452,2.176 2.219,3.53c-2.176,1.586 -4.193,-2.146 -2.219,-3.53Z" style="fill:#d189ab;fill-rule:nonzero;"/><path d="M646.61,530.924c0.867,0.737 0.867,0.737 0,0Z" style="fill:#f398bf;fill-rule:nonzero;"/><path d="M405.549,532.161c4.799,0.376 -2.385,4.235 0,0Z" style="fill:#2f2f2f;fill-rule:nonzero;"/><path d="M615.615,582.985c1.786,0.202 3.558,0.402 5.36,0.634c-0.966,1.758 -1.903,3.545 -2.825,5.331c-3.963,4.121 -7.853,8.474 -12.608,11.673c-2.508,0.158 -5.015,0.215 -7.508,0.375c0.62,-0.505 1.859,-1.499 2.478,-2.003c4.294,-3.545 8.589,-7.177 12.061,-11.558c1.052,-1.456 2.061,-2.939 3.041,-4.452Z" style="fill:#2f2f2f;fill-rule:nonzero;"/><path d="M420.116,532.32c0.737,0.766 0.737,0.766 0,0Z" style="fill:#212121;fill-rule:nonzero;"/><path d="M425.822,532.335c0.752,0.752 0.752,0.752 0,0Z" style="fill:#202021;fill-rule:nonzero;"/><path d="M431.413,532.09c4.308,-2.27 0.304,4.771 0,0Z" style="fill:#3b3b3c;fill-rule:nonzero;"/><path d="M461.76,613.045c0.81,0.693 0.81,0.693 0,0Z" style="fill:#3b3b3c;fill-rule:nonzero;"/><path d="M468.892,536.269c2.234,-1.441 4.597,-2.68 6.917,-4.02c-0.259,1.138 -0.49,2.276 -0.72,3.429c2.637,2.508 4.971,5.318 6.859,8.444c-0.533,0.793 -1.599,2.377 -2.147,3.17c-2.738,-3.286 -7.061,-6.398 -11.427,-4.077c-4.15,1.801 -4.136,6.657 -4.51,10.49l-0.677,-0.475c-2.032,-0.216 -4.078,-0.288 -6.11,-0.36c0.49,-0.404 1.455,-1.197 1.945,-1.6c3.631,-2.666 4.237,-7.464 5.389,-11.484c1.441,-1.239 2.925,-2.42 4.481,-3.516Z" style="fill:#4c4b4c;fill-rule:nonzero;"/><path d="M551.233,532.422c1.628,-0.705 3.257,-1.355 4.913,-2.003c0.159,0.533 0.475,1.614 0.62,2.148c-4.107,3.587 -7.06,9.438 -4.208,14.741c2.796,7.305 13.645,9.553 18.877,3.646c1.7,-1.441 3.213,-3.069 4.712,-4.727c0.764,-1.023 1.527,-2.06 2.291,-3.099c0.244,3.041 0.331,6.082 0.49,9.136l-1.441,-1.21c-4.784,1.829 -8.228,6.844 -13.777,6.513c-5.994,0.749 -11.7,-3.214 -14.121,-8.516c-1.225,-3.372 -1.34,-7.003 -1.859,-10.506c-3.631,3.675 -5.72,8.516 -4.711,13.748c-0.404,-1.542 -0.765,-3.099 -1.081,-4.655c0.043,-1.902 0.173,-3.804 0.389,-5.691c0.893,-1.254 1.843,-2.465 2.809,-3.66c2.191,-1.786 4.222,-3.747 6.096,-5.865Z" style="fill:#2f2d2d;fill-rule:nonzero;"/><path d="M519.386,536.312c8.732,-2.464 17.767,-4.222 26.859,-2.882c-0.36,1.614 -0.72,3.229 -1.109,4.857c-0.966,1.195 -1.917,2.407 -2.81,3.659c-7.479,-0.288 -15.173,0.865 -21.845,4.382c-2.061,1.067 -1.254,3.934 -1.83,5.792l-1.182,0.072c0.375,-5.317 0.793,-10.662 1.917,-15.879Z" style="fill:#515151;fill-rule:nonzero;"/><path d="M475.853,598.159c1.427,-0.518 2.853,-1.023 4.28,-1.541c0.303,4.726 1.196,10.778 7.09,11.196c-1.081,1.873 -1.974,3.89 -2.291,6.051c-1.801,-2.708 -3.674,-5.36 -5.75,-7.867c-1.124,-2.608 -2.233,-5.217 -3.329,-7.84Z" style="fill:#515151;fill-rule:nonzero;"/><path d="M692.274,533.761c0.781,0.781 0.781,0.781 0,0Z" style="fill:#49677f;fill-rule:nonzero;"/><path d="M402.68,535.303c0.752,0.708 0.752,0.708 0,0Z" style="fill:#333;fill-rule:nonzero;"/><path d="M476.098,627.439c0.795,0.695 0.795,0.695 0,0Z" style="fill:#333;fill-rule:nonzero;"/><path d="M416.298,534.813c1.138,0.058 3.386,0.159 4.525,0.216c0.13,1.254 0.389,3.761 0.519,5c-2.392,-0.98 -4.626,-2.465 -5.043,-5.217Z" style="fill:#141516;fill-rule:nonzero;"/><path d="M422.898,535.303c0.781,0.693 0.781,0.693 0,0Z" style="fill:#2e2e2f;fill-rule:nonzero;"/><path d="M428.59,534.988c4.38,-2.14 0.116,4.784 0,0Z" style="fill:#434344;fill-rule:nonzero;"/><path d="M441.645,618.708c0.723,0.765 0.723,0.765 0,0Z" style="fill:#434344;fill-rule:nonzero;"/><path d="M497.685,534.769c2.133,0.923 4.294,1.859 5.894,3.603c0.98,-1.153 1.945,-2.349 2.954,-3.473c1.7,5.173 1.527,10.664 2.161,16.009c0.058,0.72 0.187,2.161 0.245,2.867c-0.389,0.158 -1.167,0.446 -1.556,0.591c-0.634,0.215 -1.888,0.648 -2.507,0.878c0.231,-0.821 0.706,-2.449 0.937,-3.257c-3.041,-1.642 -6.167,-3.156 -9.352,-4.509c0.029,-4.266 0.562,-8.503 1.225,-12.71Z" style="fill:#aeabac;fill-rule:nonzero;"/><path d="M556.159,535.836c7.795,-1.816 18.978,1.037 19.986,10.39c-1.498,1.656 -3.011,3.285 -4.712,4.726c-4.582,0.174 -9.913,1.658 -13.891,-1.397c-4.208,-3.227 -3.732,-9.525 -1.383,-13.718Zm1.7,6.082c1.426,6.023 9.151,9.64 14.497,6.167c-3.646,-4.006 -8.804,-7.536 -14.497,-6.167Z" style="fill:#7c7475;fill-rule:nonzero;"/><path d="M639.678,536.787c0.649,-0.145 1.946,-0.404 2.594,-0.533l1.11,-0.231c3.198,-0.332 4.222,3.617 3.271,5.951c-1.327,1.008 -2.638,2.045 -3.934,3.097c-0.13,0.995 -0.389,2.998 -0.519,4.007l-0.044,1.944l-0.23,-1.296c-0.072,-0.317 -0.202,-0.966 -0.274,-1.283c-1.672,-0.562 -3.33,-1.124 -4.971,-1.7c-0.174,-0.707 -0.534,-2.133 -0.707,-2.838c0.116,-0.721 0.332,-2.133 0.446,-2.84l0.793,0.865c0.966,1.239 2.276,2.104 3.717,2.767c-1.325,-2.348 -2.579,-4.812 -1.643,-7.565l0.389,-0.345Z" style="fill:#d6e8f8;fill-rule:nonzero;"/><path d="M405.433,537.955c4.799,0.216 -2.226,4.322 0,0Z" style="fill:#2c2c2c;fill-rule:nonzero;"/><path d="M419.971,552.41c4.25,-2.356 0.361,4.784 0,0Z" style="fill:#2c2c2c;fill-rule:nonzero;"/><path d="M425.722,537.955c4.38,-2.096 0.072,4.785 0,0Z" style="fill:#404041;fill-rule:nonzero;"/><path d="M461.774,607.193c0.694,0.796 0.694,0.796 0,0Z" style="fill:#404041;fill-rule:nonzero;"/><path d="M578.538,537.838c2.638,1.34 5.36,2.737 7.191,5.144c5.087,6.355 9.553,13.388 12.061,21.183c2.176,5.505 -2.666,10.245 -6.139,13.891c-1.643,-10.201 -5.403,-20.044 -11.844,-28.199c0.692,-4.064 0.072,-8.156 -1.268,-12.018Z" style="fill:#363635;fill-rule:nonzero;"/><path d="M615.442,538.286c3.587,0.606 7.218,0.389 10.778,-0.331c-0.417,2.247 -0.806,4.51 -1.195,6.772c-2.911,0.058 -5.836,0.13 -8.733,0.216c-0.158,-1.599 -0.345,-3.185 -0.562,-4.755c-0.072,-0.475 -0.216,-1.428 -0.288,-1.903Z" style="fill:#d2d4d6;fill-rule:nonzero;"/><path d="M626.22,537.955c0.62,4.107 0.922,8.545 -0.936,12.406c-2.709,1.917 -6.254,1.557 -9.353,2.118c0.231,-2.508 0.361,-5.028 0.361,-7.536c2.895,-0.086 5.821,-0.158 8.733,-0.216c0.388,-2.262 0.777,-4.525 1.195,-6.772Z" style="fill:#bababc;fill-rule:nonzero;"/><path d="M663.946,538.848c3.574,-1.802 5.72,4.481 1.873,5.317c-3.328,1.153 -5.014,-4.237 -1.873,-5.317Z" style="fill:#a5c9eb;fill-rule:nonzero;"/><path d="M444.598,542.435c2.061,-2.565 5.418,-3.343 8.3,-4.597c-0.245,1.052 -0.749,3.141 -1.009,4.178c0.893,1.658 1.772,3.315 2.666,4.986c-2.32,-0.677 -4.626,-1.383 -6.96,-1.959l-0.476,0.446c-0.648,0.707 -1.974,2.089 -2.623,2.796c0.014,-1.946 -0.821,-4.05 0.101,-5.85Z" style="fill:#5172b3;fill-rule:nonzero;"/><path d="M428.475,540.823c4.308,-2.268 0.26,4.785 0,0Z" style="fill:#414041;fill-rule:nonzero;"/><path d="M456.314,549.207c2.234,-3.516 4.827,-6.815 8.098,-9.423c-1.153,4.02 -1.758,8.819 -5.389,11.484c-0.677,-0.519 -2.032,-1.542 -2.709,-2.061Z" style="fill:#141719;fill-rule:nonzero;"/><path d="M647.143,545.345c-0.475,-3.04 3.011,-5.75 5.807,-4.452c2.276,0.634 2.55,3.141 3.126,5.043c-2.636,2.377 -5.85,4.222 -9.15,1.744c0.057,-0.576 0.173,-1.744 0.216,-2.335Z" style="fill:#bed9f2;fill-rule:nonzero;"/><path d="M520.482,546.325c6.672,-3.516 14.367,-4.668 21.846,-4.38c-0.216,1.887 -0.347,3.791 -0.389,5.692c-6.557,0.101 -12.912,1.685 -18.964,4.149c-1.081,0.088 -3.242,0.26 -4.323,0.332c0.576,-1.859 -0.23,-4.727 1.83,-5.793Z" style="fill:#656565;fill-rule:nonzero;"/><path d="M557.859,541.916c5.692,-1.368 10.851,2.162 14.497,6.168c-5.346,3.473 -13.07,-0.145 -14.497,-6.168Z" style="fill:#c1bdbe;fill-rule:nonzero;"/><path d="M631.609,547.834c0.332,-0.723 0.997,-2.154 1.329,-2.862c-0.044,0.622 -0.159,1.894 -0.216,2.53l-1.113,0.332Z" style="fill:#eb88b3;fill-rule:nonzero;"/><path d="M633.15,547.911c0.705,-0.995 2.118,-3.012 2.824,-4.006c0.173,0.705 0.534,2.133 0.707,2.838c1.643,0.576 3.299,1.138 4.971,1.7c0.072,0.317 0.202,0.966 0.274,1.283c-1.109,3.027 -2.32,6.009 -3.488,9.006c2.594,0.433 5.173,0.879 7.767,1.384l-0.446,-1.47c-1.83,-0.576 -3.602,-1.197 -5.259,-2.133c1.037,-1.715 1.599,-3.545 1.658,-5.489l0.043,-1.946c3.069,-0.246 6.24,0.303 8.689,2.291c1.542,1.7 -0.086,3.026 -1.267,4.279c-0.865,3.113 -1.441,6.326 -2.724,9.309c-1.355,-1.094 -2.651,-2.247 -3.89,-3.458c-0.995,3.084 -3.992,6.124 -1.658,9.381c-2.666,-0.461 -5.317,-0.893 -7.969,-1.296c0.231,1.296 0.461,2.622 0.692,3.934c-1.21,0.215 -3.646,0.619 -4.87,0.821c0.995,-2.824 1.917,-5.678 2.478,-8.617c1.974,0.375 3.818,1.239 5.591,2.219c0.057,-0.677 0.187,-2.047 0.244,-2.724c-2.535,1.153 -4.251,0.462 -5.144,-2.06c-0.721,0.158 -2.176,0.461 -2.911,0.606c0.347,-4.569 2.781,-8.718 2.724,-13.329c2.075,-0.159 3.732,0.634 4.957,2.377c0.49,-0.576 1.485,-1.729 1.974,-2.306c-1.917,-0.894 -6.109,0.576 -5.965,-2.594Zm13.113,4.496c-1.946,0.936 -2.061,1.946 -0.317,3.027c1.946,-0.952 2.047,-1.96 0.317,-3.027Zm-11.758,2.436c-0.778,2.075 -0.159,2.767 1.843,2.075c0.808,-2.075 0.187,-2.767 -1.843,-2.075Zm-2.219,4.467c-0.606,1.96 0.116,2.651 2.161,2.075c0.62,-2.003 -0.101,-2.693 -2.161,-2.075Zm4.467,1.557c-0.606,1.917 0.116,2.666 2.176,2.262c0.649,-1.959 -0.072,-2.709 -2.176,-2.262Z" style="fill:#d475a2;fill-rule:nonzero;"/><path d="M646.081,548.254c-2.717,-0.043 -0.954,-4.74 1.069,-2.905c-0.043,0.592 -0.158,1.764 -0.216,2.342l-0.853,0.563Z" style="fill:#b06889;fill-rule:nonzero;"/><path d="M408.603,546.817c0.766,0.709 0.766,0.709 0,0Z" style="fill:#2d2d2e;fill-rule:nonzero;"/><path d="M411.528,549.613c0.708,0.752 0.708,0.752 0,0Z" style="fill:#2d2d2e;fill-rule:nonzero;"/><path d="M453.13,638.852c0.81,0.853 0.81,0.853 0,0Z" style="fill:#2d2d2e;fill-rule:nonzero;"/><path d="M420.119,546.498c4.842,0.26 -2.27,4.307 0,0Z" style="fill:#696869;fill-rule:nonzero;"/><path d="M496.46,547.481c3.185,1.353 6.311,2.867 9.352,4.509c-0.231,0.808 -0.706,2.436 -0.937,3.257c-2.493,0.938 -4.914,2.047 -7.248,3.33c-0.548,-3.69 -1.052,-7.378 -1.167,-11.095Z" style="fill:#989697;fill-rule:nonzero;"/><path d="M522.972,551.788c6.052,-2.465 12.406,-4.05 18.963,-4.15c0.317,1.557 0.677,3.112 1.081,4.654c0.202,0.721 0.604,2.177 0.821,2.897c-3.069,0.015 -6.139,0.057 -9.208,-0.086c-3.833,-1.268 -7.709,-2.478 -11.657,-3.314Z" style="fill:#777879;fill-rule:nonzero;"/><path d="M416.932,549.512c4.813,0.058 -2.096,4.395 0,0Z" style="fill:#292929;fill-rule:nonzero;"/><path d="M431.559,549.639c0.766,0.752 0.766,0.752 0,0Z" style="fill:#1c1c1d;fill-rule:nonzero;"/><path d="M479.874,552.609c3.458,0.736 6.96,0.303 10.159,-1.21c1.527,3.213 2.68,6.57 3.631,10.001c-2.81,1.988 -5.231,4.423 -7.565,6.945c-0.663,-0.116 -1.989,-0.317 -2.666,-0.418c0.548,-0.835 1.095,-1.671 1.643,-2.493c-1.455,-3.905 -3.343,-7.738 -6.845,-10.216c0.533,-0.879 1.081,-1.744 1.643,-2.608Z" style="fill:#484849;fill-rule:nonzero;"/><path d="M455.998,607.222c0.795,0.695 0.795,0.695 0,0Z" style="fill:#484849;fill-rule:nonzero;"/><path d="M579.015,553.446c0.259,-1.211 0.533,-2.407 0.792,-3.589c6.442,8.156 10.203,17.998 11.846,28.201c-4.281,4.092 -9.151,7.637 -14.669,9.842c0.345,-8.084 0.143,-18.545 -7.148,-23.632c4.641,-2.882 6.067,-8.358 7.653,-13.214l1.44,1.21l0.088,1.182Z" style="fill:#525352;fill-rule:nonzero;"/><path d="M425.477,552.48c0.564,-0.116 1.706,-0.376 2.269,-0.491l0.014,1.07c-0.535,0.158 -1.604,0.477 -2.125,0.636l-0.159,-1.215Z" style="fill:#818082;fill-rule:nonzero;"/><path d="M646.268,552.407c1.735,1.07 1.634,2.081 -0.317,3.036c-1.749,-1.084 -1.634,-2.096 0.317,-3.036Z" style="fill:#c8cbe7;fill-rule:nonzero;"/><path d="M653.036,552.78c2.089,-1.541 4.294,1.975 2.262,3.387c-2.176,1.845 -4.668,-2.06 -2.262,-3.387Z" style="fill:#985071;fill-rule:nonzero;"/><path d="M463.186,553.228l0.677,0.475c3.084,2.478 5.922,5.23 8.804,7.94c1.83,-2.176 3.675,-4.309 5.562,-6.427c3.502,2.478 5.389,6.311 6.845,10.216c-0.548,0.822 -1.095,1.658 -1.643,2.493c0.677,0.101 2.003,0.303 2.666,0.418c-2.464,3.791 -4.496,7.868 -5.937,12.162c-2.248,-1.671 -4.208,-3.688 -5.764,-6.023c-0.432,-0.288 -1.297,-0.865 -1.744,-1.138c-0.706,-6.023 -5.317,-10.245 -9.121,-14.525c-0.014,-0.49 -0.043,-1.485 -0.058,-1.99c-0.879,-0.158 -2.637,-0.475 -3.516,-0.634c1.066,-0.995 2.147,-1.988 3.228,-2.968Zm17.09,6.24c4.813,0.028 -2.075,4.38 0,0Z" style="fill:#595758;fill-rule:nonzero;"/><path d="M534.631,555.103c3.069,0.145 6.139,0.101 9.208,0.086c3.429,6.269 9.842,10.693 17.047,11.11c5.159,6.917 5.836,15.606 5.518,23.935c-1.166,0.13 -3.516,0.404 -4.683,0.534c-1.355,-6.801 -3.602,-13.459 -7.363,-19.323c-2.478,-4.641 -6.08,-8.791 -11.125,-10.664c1.801,2.983 3.934,5.793 5.476,8.934c-0.303,2.19 -2.306,3.602 -3.473,5.36l-0.518,-0.692c-1.153,-1.485 -2.276,-2.925 -3.502,-4.294c0.75,-1.773 2.724,-3.502 1.788,-5.577c-2.061,-3.703 -5.36,-6.513 -8.373,-9.41Z" style="fill:#949495;fill-rule:nonzero;"/><path d="M618.077,556.543c0.475,-0.303 1.441,-0.894 1.917,-1.182c1.874,1.298 3.69,2.724 5.505,4.136c-0.648,3.257 -1.412,6.643 -3.444,9.353c-2.968,1.109 -6.21,0.547 -9.294,0.663c0.072,-2.249 0.143,-4.482 0.288,-6.7c0.331,-1.009 0.98,-2.998 1.296,-3.992c1.614,1.384 3.214,2.767 4.828,4.165c0.98,-0.404 1.974,-0.808 2.968,-1.197c-0.332,-1.195 -0.663,-2.377 -0.98,-3.545c-0.764,-0.432 -2.306,-1.282 -3.084,-1.7Z" style="fill:#848384;fill-rule:nonzero;"/><path d="M634.505,554.843c2.039,-0.693 2.66,0 1.85,2.083c-2.009,0.693 -2.631,0 -1.85,-2.083Z" style="fill:#35212b;fill-rule:nonzero;"/><path d="M675.009,556.976c4.222,-3.271 8.516,4.265 4.05,6.269c-3.862,2.767 -7.868,-3.805 -4.05,-6.269Z" style="fill:#77b1e1;fill-rule:nonzero;"/><path d="M650.124,559.339c0.446,-3.458 3.214,-2.478 5.116,-0.576c1.239,0.606 4.826,-0.547 3.573,2.104c-1.282,2.249 -2.535,4.626 0.086,6.471l1.024,-1.729c0.461,0.619 1.397,1.887 1.858,2.508c-2.464,0.215 -4.957,-0.015 -7.378,-0.448c-0.345,-0.404 -1.037,-1.21 -1.383,-1.599l-0.159,-0.98c0.793,-0.591 2.393,-1.757 3.185,-2.348c-1.887,-1.211 -4.971,-1.254 -5.922,-3.401Z" style="fill:#aa537b;fill-rule:nonzero;"/><path d="M480.277,559.467c4.828,0.029 -2.081,4.395 0,0Z" style="fill:#6f9dc4;fill-rule:nonzero;"/><path d="M632.286,559.311c2.067,-0.622 2.789,0.072 2.168,2.081c-2.052,0.578 -2.774,-0.116 -2.168,-2.081Z" style="fill:#3e2632;fill-rule:nonzero;"/><path d="M431.471,560.81c4.394,-2.125 0.087,4.784 0,0Z" style="fill:#5a595a;fill-rule:nonzero;"/><path d="M443.199,589.687c4.452,-2.009 -0.029,4.785 0,0Z" style="fill:#5a595a;fill-rule:nonzero;"/><path d="M493.664,561.398l1.225,-0.893c-5.778,8.415 -13.243,16.887 -13.012,27.768c1.902,0.389 3.689,-0.663 5.505,-1.081c-0.115,0.547 -0.36,1.658 -0.476,2.219c-0.504,2.276 -1.037,4.538 -1.556,6.816c-1.744,0.129 -3.487,0.259 -5.216,0.389c-0.086,-5.36 -0.749,-10.765 0.029,-16.11c1.441,-4.294 3.473,-8.373 5.937,-12.162c2.334,-2.522 4.755,-4.957 7.565,-6.946Z" style="fill:#c2c2c4;fill-rule:nonzero;"/><path d="M543.233,560.78c5.043,1.873 8.646,6.023 11.125,10.664c-0.029,2.636 0.303,5.287 1.34,7.752c-2.32,0.331 -6.456,-2.104 -7.018,1.368c-1.067,-1.887 -2.219,-3.717 -3.444,-5.489c1.167,-1.758 3.17,-3.17 3.473,-5.36c-1.542,-3.142 -3.675,-5.951 -5.476,-8.934Z" style="fill:#757475;fill-rule:nonzero;"/><path d="M636.753,560.866c2.11,-0.448 2.833,0.304 2.182,2.27c-2.067,0.405 -2.789,-0.347 -2.182,-2.27Z" style="fill:#39232e;fill-rule:nonzero;"/><path d="M684.232,568.286c-4.958,-1.369 -1.369,-8.588 2.781,-6.917c4.971,1.34 2.262,9.669 -2.753,7.234c0.635,3.227 2.205,8.386 -2.306,9.423c-5.375,1.527 -7.666,-6.009 -3.242,-8.617c1.946,0.49 3.848,-0.244 5.52,-1.124Zm-3.444,4.395c0.734,0.764 0.734,0.764 0,0Z" style="fill:#5ea6db;fill-rule:nonzero;"/><path d="M698.341,560.895c4.38,-2.168 0.159,4.771 0,0Z" style="fill:#315b76;fill-rule:nonzero;"/><path d="M649.104,561.919c0.622,0.159 1.865,0.477 2.501,0.636l0.376,1.199l-0.434,1.041c-0.622,-0.058 -1.865,-0.174 -2.486,-0.231l-0.664,-1.344l0.708,-1.302Z" style="fill:#ef6fa9;fill-rule:nonzero;"/><path d="M428.645,563.793c4.365,-2.154 0.173,4.784 0,0Z" style="fill:#616061;fill-rule:nonzero;"/><path d="M434.511,563.676c4.467,-1.938 -0.116,4.784 0,0Z" style="fill:#636163;fill-rule:nonzero;"/><path d="M437.278,566.618c4.279,-2.299 0.304,4.799 0,0Z" style="fill:#636163;fill-rule:nonzero;"/><path d="M566.131,565.578c0.922,-0.332 2.781,-0.98 3.703,-1.311c7.292,5.087 7.493,15.547 7.148,23.632c-1.946,0.619 -3.891,1.153 -5.85,1.599c0.446,-8.285 -0.49,-16.744 -5,-23.92Z" style="fill:#717172;fill-rule:nonzero;"/><path d="M642.632,567.517c-0.895,-2.27 2.79,-3.787 3.528,-1.344c-0.55,0.491 -1.677,1.474 -2.226,1.966l-1.302,-0.622Z" style="fill:#f06da8;fill-rule:nonzero;"/><path d="M409.944,572.048c4.611,0.303 5.36,-7.594 9.957,-5.893c3.055,1.08 6.297,1.628 9.539,1.225c-0.461,1.34 -0.951,2.666 -1.383,4.035c1.455,-0.461 2.911,-0.923 4.366,-1.369c-0.432,1.052 -0.864,2.104 -1.282,3.17c1.917,2.118 3.804,4.279 5.649,6.484c-0.634,0.534 -1.917,1.614 -2.551,2.148c1.671,1.758 3.372,3.516 4.943,5.39c0.014,0.604 0.014,1.801 0.029,2.405l-0.375,0.361c-0.648,0.648 -1.96,1.931 -2.608,2.579l-0.49,0.417c-0.692,0.62 -2.089,1.859 -2.796,2.48c-2.968,1.642 0.043,2.55 1.931,3.486l-0.721,1.599c1.009,0.303 3.041,0.938 4.049,1.239l-2.406,1.903c-3.069,-2.32 -5.62,-5.246 -8.199,-8.07c-0.202,-1.786 -0.648,-3.516 -1.081,-5.23c0.533,-1.24 1.917,-2.652 0.504,-3.848c-4.366,-4.943 -8.819,-10.794 -15.663,-12.105c-0.36,-0.606 -1.066,-1.801 -1.412,-2.407Zm6.989,-2.464c4.352,-2.19 0.202,4.74 0,0Zm16.009,20.447c0.764,0.721 0.764,0.721 0,0Zm-2.925,2.825c0.677,0.778 0.677,0.778 0,0Z" style="fill:#9b999b;fill-rule:nonzero;"/><path d="M481.879,652.945c4.813,0.449 -2.429,4.149 0,0Z" style="fill:#9b999b;fill-rule:nonzero;"/><path d="M484.513,656.044c4.322,-2.125 0.087,4.842 0,0Z" style="fill:#9b999b;fill-rule:nonzero;"/><path d="M431.571,566.747c4.322,-2.241 0.217,4.784 0,0Z" style="fill:#646364;fill-rule:nonzero;"/><path d="M560.886,566.299c1.758,-0.173 3.502,-0.418 5.245,-0.721c4.51,7.176 5.448,15.635 5,23.92c-1.181,0.189 -3.545,0.549 -4.726,0.736c0.316,-8.329 -0.361,-17.019 -5.52,-23.935Z" style="fill:#808182;fill-rule:nonzero;"/><path d="M647.102,566.731c2.11,-0.679 4.423,1.432 3.253,3.57c-2.039,1.619 -5.233,-1.749 -3.253,-3.57Z" style="fill:#db659c;fill-rule:nonzero;"/><path d="M663.267,567.048c2.104,-1.599 4.18,2.076 2.291,3.502c-2.19,1.845 -4.467,-2.104 -2.291,-3.502Z" style="fill:#9b4a70;fill-rule:nonzero;"/><path d="M692.303,566.76c4.496,-2.983 8.588,4.726 3.89,6.873c-4.265,2.55 -8.084,-4.496 -3.89,-6.873Z" style="fill:#399cd6;fill-rule:nonzero;"/><path d="M615.729,580.723c4.323,-4.136 6.095,-10.893 11.989,-13.243l-0.562,1.773c-0.231,0.777 -0.677,2.319 -0.894,3.097l-0.389,1.197c-1.34,3.486 -3.099,6.801 -4.9,10.073c-1.801,-0.231 -3.573,-0.433 -5.36,-0.635c0.029,-0.562 0.086,-1.685 0.116,-2.262Z" style="fill:#504e4e;fill-rule:nonzero;"/><path d="M416.931,569.587c4.365,-2.198 0.202,4.755 0,0Z" style="fill:#4f4f50;fill-rule:nonzero;"/><path d="M456.128,647.368c4.828,0.376 -2.27,4.265 0,0Z" style="fill:#4f4f50;fill-rule:nonzero;"/><path d="M440.189,569.527c4.322,-2.226 0.188,4.785 0,0Z" style="fill:#626162;fill-rule:nonzero;"/><path d="M430.017,592.857c0.679,0.78 0.679,0.78 0,0Z" style="fill:#626162;fill-rule:nonzero;"/><path d="M627.16,569.253c3.007,-0.333 1.445,4.943 -0.896,3.108c0.216,-0.781 0.665,-2.328 0.896,-3.108Z" style="fill:#905771;fill-rule:nonzero;"/><path d="M641.58,570.723c4.307,-2.284 0.246,4.857 0,0Z" style="fill:#b1517e;fill-rule:nonzero;"/><path d="M443.057,572.41c4.322,-2.27 0.275,4.769 0,0Z" style="fill:#5c5c5d;fill-rule:nonzero;"/><path d="M608.206,574.166c0.692,-0.648 2.104,-1.975 2.796,-2.623c1.6,3.04 3.214,6.082 4.727,9.179c-0.029,0.576 -0.086,1.701 -0.116,2.263c-0.98,1.513 -1.988,2.996 -3.04,4.452c-3.488,-1.426 -6.831,-3.17 -9.957,-5.259c2.348,-2.291 4.107,-5.087 5.59,-8.011Z" style="fill:#e6e6e7;fill-rule:nonzero;"/><path d="M680.788,572.682c0.738,0.766 0.738,0.766 0,0Z" style="fill:#3d5f77;fill-rule:nonzero;"/><path d="M625.875,573.547c1.887,1.325 4.092,1.557 6.368,1.47l-0.547,1.383c-0.85,1.801 -1.167,3.791 -1.34,5.764c-0.332,0.576 -0.995,1.729 -1.312,2.306c-0.173,0.907 -0.503,2.709 -0.663,3.602c-2.247,1.413 -4.351,-1.138 -6.455,-1.729c1.426,3.257 -0.145,6.139 -3.949,5.059l0.173,-2.45c0.923,-1.786 1.859,-3.574 2.825,-5.331c1.801,-3.271 3.559,-6.585 4.9,-10.073Zm-0.707,11.354c2.247,-0.922 1.397,-4.077 2.089,-5.95c-0.029,-0.202 -0.072,-0.591 -0.101,-0.793c-2.075,0.244 -5.188,5.821 -1.988,6.743Z" style="fill:#ce5790;fill-rule:nonzero;"/><path d="M700.891,573.806c2.363,-1.757 5.388,-0.143 4.006,2.911c-1.931,1.729 -5.533,-0.26 -4.006,-2.911Z" style="fill:#1e6891;fill-rule:nonzero;"/><path d="M440.304,575.408c4.221,-2.399 0.419,4.785 0,0Z" style="fill:#626163;fill-rule:nonzero;"/><path d="M455.896,575.421c4.307,-2.284 0.318,4.784 0,0Z" style="fill:#393a3b;fill-rule:nonzero;"/><path d="M686.639,575.477c2.219,-1.816 6.744,-0.648 6.542,2.594c0.966,3.141 -2.752,5.937 -5.533,4.409c-2.853,-1.024 -3.2,-5.144 -1.009,-7.003Z" style="fill:#3b9cd6;fill-rule:nonzero;"/><path d="M631.696,576.4l0.85,-0.029c1.369,2.076 0.044,4.497 -1.441,6.082l-0.749,-0.288c0.173,-1.974 0.49,-3.963 1.34,-5.764Z" style="fill:#bbadd3;fill-rule:nonzero;"/><path d="M632.546,576.372c2.017,-1.584 5.159,1.586 3.717,3.617c-0.894,2.493 -1.225,7.163 -5.188,5.461c0,-0.749 0.015,-2.247 0.029,-2.996c1.484,-1.586 2.809,-4.007 1.441,-6.082Z" style="fill:#c55e94;fill-rule:nonzero;"/><path d="M660.343,577.091c4.769,0.029 -2.053,4.424 0,0Z" style="fill:#492838;fill-rule:nonzero;"/><path d="M664.707,581.386c-2.825,-1.052 0.375,-5.318 2.421,-4.02c1.181,1.599 -0.303,4.553 -2.421,4.02Z" style="fill:#833b61;fill-rule:nonzero;"/><path d="M548.681,580.564c0.562,-3.472 4.698,-1.037 7.017,-1.368c0.347,0.907 1.039,2.752 1.399,3.674c-1.456,1.268 -2.882,2.537 -4.323,3.805c-1.384,-2.032 -2.781,-4.05 -4.092,-6.111Z" style="fill:#e3e2e4;fill-rule:nonzero;"/><path d="M505.998,589.325c2.781,-8.199 12.436,-11.354 20.087,-8.112c1.657,1.7 3.184,3.559 4.424,5.605c-0.519,-0.547 -1.556,-1.658 -2.075,-2.219c-0.893,-0.547 -2.709,-1.656 -3.602,-2.205l0.562,-0.259c-3.502,-0.375 -7.205,-1.138 -10.577,0.332c-4.136,1.383 -6.47,5.317 -9.294,8.329l0.475,-1.47Z" style="fill:#cecfd1;fill-rule:nonzero;"/><path d="M418.26,600.911c1.98,0.823 2.053,1.806 0.246,2.948c-1.937,-0.823 -2.009,-1.806 -0.246,-2.948Z" style="fill:#cecfd1;fill-rule:nonzero;"/><path d="M437.178,604.238c2.507,-1.181 5.288,-1.166 7.997,-1.225c-0.865,2.133 -1.715,4.281 -2.55,6.427c0.937,-0.244 2.81,-0.734 3.746,-0.993c-1.167,3.472 2.205,4.999 4.381,6.829c0.058,0.879 0.173,2.623 0.245,3.502c-1.383,-1.268 -2.594,-2.68 -3.646,-4.222c-0.389,0.764 -1.153,2.276 -1.542,3.026c-0.504,-0.143 -1.528,-0.446 -2.032,-0.606c1.052,-1.368 1.787,-2.895 2.205,-4.567c-1.628,0.635 -3.17,1.456 -4.726,2.219c-0.173,-0.274 -0.519,-0.821 -0.677,-1.096c1.038,-0.835 2.061,-1.671 3.069,-2.535c-2.19,-2.162 -5.144,-3.89 -6.47,-6.759Zm3.026,0.317c4.496,-2.017 -0.216,4.885 0,0Z" style="fill:#cecfd1;fill-rule:nonzero;"/><path d="M434.712,632.209c-0.663,-3.415 3.199,-4.178 5.13,-5.979c-0.836,1.917 -1.542,3.933 -1.614,6.051l-0.072,0.736c-0.591,0.215 -1.772,0.634 -2.363,0.835c-0.072,0.029 -0.23,0.086 -0.303,0.116l0.014,0c0.735,0.576 2.219,1.729 2.968,2.306l0.418,0.331l0.62,-0.23c1.614,0.072 3.17,0.634 4.539,1.498c-4.179,1.21 -0.504,6.801 2.162,3.343c0.908,2.017 1.989,3.949 3.055,5.893c-0.677,0.98 -1.34,1.96 -1.989,2.955c-0.937,2.291 -0.086,3.242 2.551,2.867c-0.086,0.778 -0.259,2.319 -0.331,3.084c0.864,-0.029 2.623,-0.101 3.502,-0.13c-0.519,1.584 -1.556,2.709 -3.113,3.372c-0.36,-0.75 -1.095,-2.234 -1.455,-2.983c-3.761,-0.894 0.101,3.156 1.398,3.789c-0.634,0.448 -1.902,1.355 -2.536,1.801c-0.576,-2.723 -2.046,-3.53 -4.424,-2.392c1.124,1.182 2.262,2.379 3.43,3.559c-2.017,-0.288 -7.003,-1.412 -5.663,-4.409c0.865,-0.288 2.608,-0.893 3.487,-1.195c-2.968,-2.377 -6.023,2.19 -7.637,4.38c-0.389,-0.418 -1.182,-1.268 -1.571,-1.685c1.96,-2.205 4.726,-4.079 5.159,-7.234c-1.787,1.34 -3.502,2.767 -5.361,4.006c-0.144,0.576 -0.432,1.758 -0.562,2.349c-0.648,-0.534 -1.931,-1.614 -2.565,-2.148c0.85,-0.663 2.551,-2.003 3.401,-2.666c-0.692,-0.475 -2.075,-1.426 -2.767,-1.902c-0.274,0.951 -0.836,2.853 -1.11,3.804c-3.357,-1.513 -1.527,-3.257 0.807,-4.683c-1.354,-1.296 -2.709,-2.55 -3.833,-4.05c-2.363,-1.397 -2.868,-3.573 0.519,-4.034c0.086,1.065 0.288,3.198 0.375,4.265c0.663,-0.692 1.989,-2.089 2.666,-2.796l-2.349,-1.195l-0.648,-3.156c1.888,0.993 3.689,2.161 5.548,3.227c-1.138,-3.113 -4.395,-4.799 -5.62,-7.796c0.821,-0.259 2.435,-0.792 3.242,-1.052c-0.274,-2.045 0.692,-2.996 2.896,-2.853Zm-2.248,4.107c0.922,0.865 0.922,0.865 0,0Zm3.257,2.68c0.764,0.894 0.764,0.894 0,0Zm-3.041,8.92c0.836,0.835 0.836,0.835 0,0Zm2.824,2.392c4.712,0.389 -2.392,4.279 0,0Zm8.761,2.926c4.755,0.043 -2.017,4.437 0,0Z" style="fill:#cecfd1;fill-rule:nonzero;"/><path d="M411.312,581.257c4.25,-2.371 0.39,4.784 0,0Z" style="fill:#7f7f80;fill-rule:nonzero;"/><path d="M445.938,581.056c4.337,-2.198 0.202,4.799 0,0Z" style="fill:#5e5c5e;fill-rule:nonzero;"/><path d="M526.085,581.212c6.7,0.534 14.093,-0.979 20.173,2.638c3.3,3.458 2.926,8.56 3.099,12.983c-4.54,-2.075 -9.237,-3.818 -14.078,-4.999c-0.187,1.08 -0.549,3.227 -0.721,4.309c-0.216,0.59 -0.634,1.743 -0.835,2.319c-0.533,-4.006 -1.383,-8.026 -3.213,-11.643c-1.239,-2.047 -2.767,-3.905 -4.424,-5.606Z" style="fill:#434342;fill-rule:nonzero;"/><path d="M548.681,580.564c1.311,2.061 2.708,4.079 4.092,6.109c1.441,-1.267 2.867,-2.535 4.323,-3.804c0.576,2.205 0.98,4.439 1.34,6.7c-1.887,0.663 -3.791,1.34 -5.663,2.017c-0.576,2.249 -1.138,4.51 -1.628,6.801c0.835,-6.038 0.187,-12.305 -2.464,-17.825Z" style="fill:#c5c3c5;fill-rule:nonzero;"/><path d="M658.701,581.604c4.799,0.288 -2.284,4.307 0,0Z" style="fill:#86335c;fill-rule:nonzero;"/><path d="M505.523,590.796c2.824,-3.012 5.159,-6.946 9.294,-8.329c3.372,-1.47 7.075,-0.707 10.577,-0.332l-0.562,0.259c-4.971,2.335 -11.038,4.064 -13.718,9.324c-1.672,4.755 4.078,7.622 8.069,6.7c3.847,-0.288 4.395,-4.799 5.879,-7.521l0.721,-1.628l0.749,0.518c0.764,5.144 -2.363,10.304 -7.623,11.225c-5.634,1.671 -8.747,-4.237 -11.888,-7.681c-1.153,3.703 -0.865,7.551 1.196,10.88l0.259,0.59c-3.17,4.482 -4.885,9.713 -5.706,15.102c-2.493,-1.946 -4.784,-4.165 -6.845,-6.572c2.421,-7.507 9.323,-13.631 8.415,-21.974c0.418,-0.505 1.239,-1.527 1.657,-2.032l-0.476,1.47Z" style="fill:#4f4f4f;fill-rule:nonzero;"/><path d="M661.58,582.049c2.579,0 5.115,0.288 4.726,3.574c1.009,-0.158 3.012,-0.505 4.02,-0.663c-0.404,0.778 -1.195,2.348 -1.599,3.126c-0.562,-0.49 -1.672,-1.484 -2.234,-1.988c-1.181,0.173 -3.53,0.49 -4.712,0.648c-0.057,-1.166 -0.143,-3.53 -0.201,-4.698Z" style="fill:#ac3b73;fill-rule:nonzero;"/><path d="M448.862,583.938c4.221,-2.415 0.434,4.769 0,0Z" style="fill:#666566;fill-rule:nonzero;"/><path d="M511.115,591.717c2.68,-5.259 8.747,-6.989 13.718,-9.324c0.562,2.45 1.182,4.914 1.7,7.392l-0.749,-0.518c-0.677,-0.389 -2.017,-1.153 -2.68,-1.542c-4.006,1.167 -7.536,3.602 -9.006,7.651c4.453,1.311 8.084,-1.426 10.966,-4.481c-1.484,2.723 -2.032,7.233 -5.879,7.521c-3.991,0.922 -9.741,-1.946 -8.069,-6.7Z" style="fill:#7d7576;fill-rule:nonzero;"/><path d="M524.832,582.394c0.893,0.549 2.709,1.658 3.602,2.205c0.879,5.649 1.282,12.523 -3.977,16.255c-4.467,4.265 -10.62,1.944 -15.62,0.215c-0.159,0.793 -0.461,2.364 -0.62,3.142c-2.061,-3.33 -2.349,-7.177 -1.196,-10.88c3.141,3.444 6.254,9.353 11.888,7.681c5.26,-0.922 8.386,-6.082 7.623,-11.225c-0.519,-2.478 -1.138,-4.942 -1.7,-7.392Z" style="fill:#2e2d2c;fill-rule:nonzero;"/><path d="M441.473,587.048c0.752,0.737 0.752,0.737 0,0Z" style="fill:#4b4a4b;fill-rule:nonzero;"/><path d="M453.186,598.519c0.737,0.752 0.737,0.752 0,0Z" style="fill:#4b4a4b;fill-rule:nonzero;"/><path d="M453.157,610.176c0.766,0.724 0.766,0.724 0,0Z" style="fill:#4b4a4b;fill-rule:nonzero;"/><path d="M445.967,586.92c4.192,-2.472 0.52,4.756 0,0Z" style="fill:#605f61;fill-rule:nonzero;"/><path d="M451.847,586.805c4.307,-2.241 0.246,4.784 0,0Z" style="fill:#656465;fill-rule:nonzero;"/><path d="M630.053,586.862c2.371,-0.752 3.15,0.058 2.371,2.4c-2.356,0.752 -3.152,-0.044 -2.371,-2.4Z" style="fill:#bc457f;fill-rule:nonzero;"/><path d="M514.096,595.376c1.47,-4.05 5,-6.484 9.006,-7.651c0.663,0.389 2.003,1.153 2.68,1.542l-0.72,1.628c-2.882,3.055 -6.513,5.792 -10.966,4.481Z" style="fill:#b1acad;fill-rule:nonzero;"/><path d="M432.941,590.031c0.766,0.723 0.766,0.723 0,0Z" style="fill:#595859;fill-rule:nonzero;"/><path d="M438.835,590.003l0.376,-0.361l0,0.752l-0.376,-0.39Z" style="fill:#4a494b;fill-rule:nonzero;"/><path d="M552.773,591.587c1.874,-0.677 3.776,-1.355 5.663,-2.017c0.116,2.249 0.143,4.496 0.116,6.744c-3.805,1.498 -6.153,4.726 -8.2,8.098l-0.114,-0.993c0.23,-1.268 0.677,-3.776 0.907,-5.03c0.49,-2.291 1.052,-4.553 1.628,-6.801Z" style="fill:#aca9aa;fill-rule:nonzero;"/><path d="M623.137,589.628c1.917,-2.55 5.562,1.312 3.099,3.17c-1.902,1.758 -4.683,-1.282 -3.099,-3.17Z" style="fill:#cd4688;fill-rule:nonzero;"/><path d="M423.907,592.899c4.799,-0.116 -1.923,4.452 0,0Z" style="fill:#848485;fill-rule:nonzero;"/><path d="M435.737,593.002l0.494,-0.421c-0.102,0.131 -0.32,0.392 -0.421,0.523l-0.073,-0.102Z" style="fill:#49494a;fill-rule:nonzero;"/><path d="M441.429,592.915c0.766,0.737 0.766,0.737 0,0Z" style="fill:#424243;fill-rule:nonzero;"/><path d="M493.564,605.767c1.96,-5.836 6.672,-10.073 10.778,-14.41c0.908,8.343 -5.994,14.468 -8.415,21.976c-1.282,-2.276 -3.357,-4.799 -2.363,-7.565Z" style="fill:#3a3939;fill-rule:nonzero;"/><path d="M535.28,591.833c4.841,1.182 9.539,2.926 14.078,5c0.143,2.205 0.505,4.395 0.879,6.585l0.114,0.995l-1.065,-0.475l-0.808,-0.505c-4.366,-2.925 -9.035,-5.36 -13.92,-7.292c0.174,-1.08 0.534,-3.227 0.721,-4.309Z" style="fill:#606060;fill-rule:nonzero;"/><path d="M619.07,592.395c2.867,2.176 3.027,5.649 3.271,8.948c-2.579,-0.604 -4.841,-1.83 -5.792,-4.452c0.85,-1.499 1.685,-2.998 2.521,-4.496Z" style="fill:#97c1e7;fill-rule:nonzero;"/><path d="M579.761,599.124c3.833,-1.384 7.42,-3.488 11.398,-4.51c3.415,0.619 6.311,2.809 9.351,4.38c-0.62,0.505 -1.859,1.498 -2.478,2.003c-4.035,2.392 -8.171,4.626 -12.508,6.456c-1.166,-3.271 -1.887,-7.292 -5.763,-8.329Z" style="fill:#989596;fill-rule:nonzero;"/><path d="M501.028,620.869c6.83,4.496 15.087,5.649 23.113,5.173l1.47,1.397c-0.893,0.288 -2.651,0.85 -3.545,1.125c-6.225,-0.015 -13.444,-3.17 -18.646,1.757c-2.32,-1.124 -4.554,-2.407 -6.7,-3.804c1.412,-0.995 2.824,-1.974 4.251,-2.939c0.014,-0.677 0.043,-2.032 0.058,-2.709Z" style="fill:#989596;fill-rule:nonzero;"/><path d="M420.217,595.594c2.024,-0.464 2.703,0.303 2.024,2.312c-2.038,0.448 -2.718,-0.317 -2.024,-2.312Z" style="fill:#777779;fill-rule:nonzero;"/><path d="M444.295,595.708c0.752,0.737 0.752,0.737 0,0Z" style="fill:#494849;fill-rule:nonzero;"/><path d="M458.893,604.327c0.665,0.81 0.665,0.81 0,0Z" style="fill:#494849;fill-rule:nonzero;"/><path d="M450.232,595.752c0.737,0.752 0.737,0.752 0,0Z" style="fill:#474747;fill-rule:nonzero;"/><path d="M534.559,596.141c4.885,1.931 9.553,4.366 13.919,7.292c-0.705,1.7 -1.355,3.429 -2.118,5.1c-3.804,-3.991 -8.731,-6.556 -14.323,-6.556c0.62,-1.153 1.182,-2.32 1.686,-3.517c0.202,-0.575 0.62,-1.728 0.836,-2.319Z" style="fill:#767677;fill-rule:nonzero;"/><path d="M550.352,604.412c2.047,-3.372 4.395,-6.601 8.199,-8.099c-0.028,0.908 -0.086,2.724 -0.101,3.631c-0.576,2.421 -1.138,4.87 -1.801,7.277c-2.06,0.231 -4.121,0.475 -6.181,0.721c-0.216,-0.721 -0.649,-2.177 -0.865,-2.911l-0.317,-1.094l1.067,0.475Z" style="fill:#8e8c8d;fill-rule:nonzero;"/><path d="M628.05,597.006c3.227,-0.418 6.095,1.239 6.455,4.654c-2.853,0.634 -5.259,2.377 -7.982,3.299c-0.36,-0.907 -1.096,-2.737 -1.47,-3.644c1.023,-1.441 2.017,-2.869 2.998,-4.309Z" style="fill:#85b8e3;fill-rule:nonzero;"/><path d="M458.864,598.605c0.741,0.726 0.741,0.726 0,0Z" style="fill:#585758;fill-rule:nonzero;"/><path d="M567.573,601.92c4.149,-0.418 8.3,-1.239 12.191,-2.796c3.876,1.037 4.597,5.058 5.763,8.329c-8.559,3.516 -17.853,4.525 -27.032,4.538c0.806,-3.257 1.671,-6.513 2.334,-9.812c2.234,-0.029 4.496,0 6.744,-0.259Zm9.322,-0.303c0.376,1.671 0.563,3.516 1.946,4.74c2.276,-0.677 1.917,-2.91 1.946,-4.799c-0.966,0.015 -2.911,0.044 -3.891,0.058Z" style="fill:#767373;fill-rule:nonzero;"/><path d="M429.856,601.588c0.766,0.867 0.766,0.867 0,0Z" style="fill:#8d8d8f;fill-rule:nonzero;"/><path d="M456.012,601.43c0.795,0.693 0.795,0.693 0,0Z" style="fill:#4a494a;fill-rule:nonzero;"/><path d="M461.774,601.501c0.766,0.737 0.766,0.737 0,0Z" style="fill:#424242;fill-rule:nonzero;"/><path d="M467.51,601.43c0.694,0.78 0.694,0.78 0,0Z" style="fill:#3c3c3d;fill-rule:nonzero;"/><path d="M473.347,601.515c0.723,0.766 0.723,0.766 0,0Z" style="fill:#353435;fill-rule:nonzero;"/><path d="M476.127,604.442c0.781,0.708 0.781,0.708 0,0Z" style="fill:#353435;fill-rule:nonzero;"/><path d="M532.038,601.976c5.591,0 10.52,2.565 14.324,6.557c-0.461,0.821 -1.399,2.449 -1.859,3.257c-4.51,-3.631 -10.014,-5.491 -15.764,-5.779c1.153,-1.298 2.248,-2.666 3.3,-4.035Z" style="fill:#8c8c8d;fill-rule:nonzero;"/><path d="M421.314,604.541c0.781,0.737 0.781,0.737 0,0Z" style="fill:#545455;fill-rule:nonzero;"/><path d="M470.435,604.369c0.723,0.766 0.723,0.766 0,0Z" style="fill:#343435;fill-rule:nonzero;"/><path d="M502.77,619.903c0.821,-5.39 2.536,-10.62 5.706,-15.101c2.406,1.527 4.914,2.925 7.551,4.02c-3.257,4.395 -5.245,9.553 -4.813,15.086c-2.911,-1.138 -5.721,-2.493 -8.444,-4.006Z" style="fill:#707071;fill-rule:nonzero;"/><path d="M549.602,605.032c0.216,0.734 0.648,2.19 0.865,2.911c-5.028,9.77 -14.007,17.219 -24.857,19.496l-1.47,-1.397c3.487,-0.692 6.859,-1.802 10.26,-2.854c0.303,-0.648 0.907,-1.917 1.21,-2.55c-3.53,1.614 -7.118,3.141 -10.922,3.905c-0.259,-0.806 -0.793,-2.434 -1.052,-3.242c2.061,-2.737 4.208,-6.844 8.343,-5.937c2.724,-0.202 3.848,2.335 4.092,4.655c0.475,0.518 0.951,1.023 1.441,1.541c4.799,-4.87 9.986,-9.798 12.09,-16.527Z" style="fill:#d4d8db;fill-rule:nonzero;"/><path d="M435.751,607.237c0.723,0.781 0.723,0.781 0,0Z" style="fill:#4d4c4d;fill-rule:nonzero;"/><path d="M468.086,607.222l0.13,0.088l0.116,0.231c-0.058,-0.073 -0.188,-0.231 -0.246,-0.319Z" style="fill:#383839;fill-rule:nonzero;"/><path d="M470.364,659.141c0.752,0.723 0.752,0.723 0,0Z" style="fill:#383839;fill-rule:nonzero;"/><path d="M473.331,607.208c0.766,0.695 0.766,0.695 0,0Z" style="fill:#464547;fill-rule:nonzero;"/><path d="M487.224,607.813l1.744,-1.109c1.599,3.646 3.732,7.075 6.528,9.943c-1.787,1.34 -3.574,2.695 -5.346,4.035c-1.873,-2.177 -3.631,-4.439 -5.216,-6.816c0.317,-2.162 1.21,-4.18 2.291,-6.052Z" style="fill:#e6e5e7;fill-rule:nonzero;"/><path d="M521.705,609.095c2.45,-0.734 4.813,-1.786 7.032,-3.084c5.749,0.288 11.254,2.148 15.765,5.779c-1.153,1.498 -2.364,2.983 -3.646,4.366c-3.833,-5.475 -12.465,-7.118 -17.191,-1.93c-3.242,2.565 -3.199,6.916 -3.689,10.648c-1.081,-0.057 -3.228,-0.187 -4.294,-0.244c-0.072,-5.836 2.234,-11.182 6.023,-15.534Z" style="fill:#a3a5a6;fill-rule:nonzero;"/><path d="M550.466,607.943c2.06,-0.246 4.121,-0.49 6.181,-0.721c-5.461,16.83 -23.473,28.272 -41.024,26.312c2.089,-1.715 4.208,-3.429 6.441,-4.971c0.893,-0.274 2.651,-0.835 3.545,-1.124c10.851,-2.276 19.828,-9.726 24.857,-19.496Z" style="fill:#636161;fill-rule:nonzero;"/><path d="M595.598,609.283c0.116,-0.85 0.347,-2.565 0.448,-3.415c0.764,1.283 1.454,2.767 2.031,4.178l0.029,0.433c0.49,5.418 0.288,10.879 0.721,16.298c1.484,0.49 2.996,0.878 4.553,1.166l0.547,0.116c-6.758,3.286 -14.51,2.709 -21.355,0.058c1.744,-0.663 3.502,-1.355 5.259,-2.032c-1.052,-0.562 -2.075,-1.11 -3.097,-1.643c4.351,0.922 8.746,0.317 12.867,-1.254c-3.271,-3.126 -4.121,-7.91 -1.239,-11.672c-1.325,-0.562 -2.622,-1.153 -3.905,-1.773c0.793,-0.114 2.349,-0.345 3.141,-0.461Z" style="fill:#f7b127;fill-rule:nonzero;"/><path d="M438.532,610.233c0.824,0.823 0.824,0.823 0,0Z" style="fill:#8a8a8c;fill-rule:nonzero;"/><path d="M458.965,610.12c0.741,0.741 0.741,0.741 0,0Z" style="fill:#474748;fill-rule:nonzero;"/><path d="M491.531,668.251c-0.043,-0.174 -0.101,-0.521 -0.145,-0.695l0.145,0.695Z" style="fill:#474748;fill-rule:nonzero;"/><path d="M464.73,610.076c0.737,0.765 0.737,0.765 0,0Z" style="fill:#3f3e3f;fill-rule:nonzero;"/><path d="M476.171,610.106c0.694,0.78 0.694,0.78 0,0Z" style="fill:#464647;fill-rule:nonzero;"/><path d="M511.215,623.908c-0.432,-5.533 1.556,-10.692 4.813,-15.086c1.888,0.158 3.775,0.244 5.677,0.274c-3.79,4.352 -6.095,9.698 -6.023,15.534c-1.124,-0.173 -3.343,-0.549 -4.467,-0.721Z" style="fill:#8f8f90;fill-rule:nonzero;"/><path d="M598.078,610.048c3.89,-1.197 8.876,-0.389 10.793,3.631c3.027,5.259 -0.215,11.975 -5.489,14.266c-1.557,-0.288 -3.069,-0.677 -4.554,-1.167c-0.432,-5.418 -0.23,-10.879 -0.72,-16.298l-0.029,-0.432Z" style="fill:#f8db24;fill-rule:nonzero;"/><path d="M523.665,614.226c4.726,-5.188 13.358,-3.545 17.191,1.93c-1.57,1.327 -3.156,2.608 -4.784,3.862c-0.244,-2.32 -1.369,-4.856 -4.092,-4.654c-4.136,-0.908 -6.283,3.198 -8.343,5.937c0.259,0.806 0.793,2.434 1.052,3.242c-1.182,0.086 -3.53,0.244 -4.712,0.331c0.49,-3.732 0.447,-8.083 3.689,-10.648Z" style="fill:#bcbec0;fill-rule:nonzero;"/><path d="M479.096,612.986c0.737,0.766 0.737,0.766 0,0Z" style="fill:#313132;fill-rule:nonzero;"/><path d="M473.331,624.5c0.752,0.752 0.752,0.752 0,0Z" style="fill:#313132;fill-rule:nonzero;"/><path d="M438.776,615.926c0.741,0.741 0.741,0.741 0,0Z" style="fill:#39393a;fill-rule:nonzero;"/><path d="M484.816,667.772c0.766,0.708 0.766,0.708 0,0Z" style="fill:#39393a;fill-rule:nonzero;"/><path d="M458.806,616.026c0.781,0.794 0.781,0.794 0,0Z" style="fill:#181819;fill-rule:nonzero;"/><path d="M464.585,616.288l-0.102,-0.232l0.436,0.494c-0.087,-0.073 -0.247,-0.19 -0.334,-0.262Z" style="fill:#2a2b2c;fill-rule:nonzero;"/><path d="M569.574,615.348c4.496,2.608 8.142,6.542 13.199,8.185c0.086,-0.922 0.274,-2.739 0.36,-3.66c0.058,1.268 0.116,2.537 0.174,3.818l1.426,0.749c1.023,0.534 2.045,1.081 3.097,1.643c-1.757,0.677 -3.516,1.369 -5.259,2.032c-5.375,-2.493 -9.021,-8.329 -12.997,-12.767Z" style="fill:#ad4e23;fill-rule:nonzero;"/><path d="M495.494,616.645c1.758,1.498 3.617,2.911 5.533,4.222c-0.014,0.677 -0.043,2.032 -0.058,2.709c-1.427,0.964 -2.839,1.944 -4.251,2.939c-2.349,-1.773 -4.568,-3.703 -6.571,-5.836c1.772,-1.34 3.559,-2.695 5.346,-4.035Z" style="fill:#b7b4b5;fill-rule:nonzero;"/><path d="M556.39,616.762c2.897,-0.029 5.85,-0.259 8.747,0.202c5.461,3.501 10.029,8.386 13.675,13.746c-2.867,0.347 -5.706,0.692 -8.545,1.052c-3.905,-5.649 -8.847,-10.389 -13.876,-15Z" style="fill:#ef4023;fill-rule:nonzero;"/><path d="M542.613,630.147c3.905,-2.867 7.018,-6.657 10.374,-10.115c1.11,4.366 2.566,8.632 4.251,12.81c0.922,-2.377 1.527,-8.602 5.23,-6.268c1.11,4.452 1.96,8.977 2.177,13.559c-1.874,-0.158 -3.761,-0.303 -5.621,-0.461c-1.225,-2.061 -2.478,-4.107 -3.775,-6.095c0.274,3.169 0.692,6.325 1.167,9.482c-0.404,-0.576 -1.211,-1.729 -1.614,-2.306c-1.917,-0.187 -3.833,-0.389 -5.75,-0.62c-3.344,1.067 -6.601,2.377 -9.742,3.934c-0.778,-0.36 -2.306,-1.052 -3.069,-1.397c-0.086,-1.658 -0.143,-3.271 -0.202,-4.9c-1.383,0.85 -2.795,1.671 -4.207,2.449c0.043,-1.902 0.101,-3.804 0.173,-5.691c3.559,-1.384 7.305,-2.407 10.606,-4.382Zm8.415,-5.375c-1.182,2.047 0.015,4.885 1.786,6.24c1.801,1.254 1.744,-1.542 1.009,-2.407c-0.764,-1.195 -0.966,-4.006 -2.796,-3.833Zm-3.126,10.102c-0.475,2.032 0.274,2.737 2.262,2.089c0.461,-2.045 -0.288,-2.737 -2.262,-2.089Zm-2.753,2.737c4.799,0.332 -2.319,4.222 0,0Z" style="fill:#3e4244;fill-rule:nonzero;"/><path d="M438.72,621.66c0.781,0.708 0.781,0.708 0,0Z" style="fill:#353536;fill-rule:nonzero;"/><path d="M444.483,621.719c0.781,0.838 0.781,0.838 0,0Z" style="fill:#818184;fill-rule:nonzero;"/><path d="M464.585,653.29c0.795,0.839 0.795,0.839 0,0Z" style="fill:#818184;fill-rule:nonzero;"/><path d="M450.218,621.776c0.853,0.854 0.853,0.854 0,0Z" style="fill:#9e9c9e;fill-rule:nonzero;"/><path d="M455.594,621.273c4.857,0.347 -2.327,4.263 0,0Z" style="fill:#7e7c7e;fill-rule:nonzero;"/><path d="M470.364,621.66c0.737,0.794 0.737,0.794 0,0Z" style="fill:#1a1a1b;fill-rule:nonzero;"/><path d="M476.171,621.719c0.752,0.708 0.752,0.708 0,0Z" style="fill:#444446;fill-rule:nonzero;"/><path d="M435.795,624.63c0.824,0.708 0.824,0.708 0,0Z" style="fill:#5a5b5c;fill-rule:nonzero;"/><path d="M441.486,624.687c0.882,0.794 0.882,0.794 0,0Z" style="fill:#949496;fill-rule:nonzero;"/><path d="M447.293,624.643c0.824,0.895 0.824,0.895 0,0Z" style="fill:#a4a3a5;fill-rule:nonzero;"/><path d="M453.013,624.643c0.81,0.867 0.81,0.867 0,0Z" style="fill:#9c9b9d;fill-rule:nonzero;"/><path d="M551.03,624.772c1.83,-0.173 2.032,2.636 2.796,3.833c0.734,0.865 0.793,3.66 -1.009,2.407c-1.772,-1.355 -2.968,-4.193 -1.786,-6.24Z" style="fill:#0d0f10;fill-rule:nonzero;"/><path d="M432.912,627.584c0.795,0.796 0.795,0.796 0,0Z" style="fill:#757577;fill-rule:nonzero;"/><path d="M443.158,629.068c0.202,-0.619 0.605,-1.858 0.807,-2.478l1.671,-0.043c0.202,0.749 0.576,2.233 0.764,2.968c0.576,-0.145 1.7,-0.418 2.262,-0.562c0.749,0.907 2.234,2.737 2.968,3.659c0.159,-0.979 0.461,-2.939 0.62,-3.919l2.205,0.865c0.144,0.62 0.447,1.845 0.591,2.45c0.864,0.187 2.579,0.547 3.444,0.72c-1.196,1.167 -2.551,2.118 -4.092,2.84c0.764,0.734 2.306,2.218 3.069,2.954c0.216,-0.908 0.634,-2.709 0.85,-3.603l2.507,0.534c-0.677,-2.003 -1.326,-3.992 -2.003,-5.966c2.277,1.355 4.237,3.358 6.657,4.439c0.836,-2.061 1.124,-4.251 -0.735,-5.836l0.49,0.547c0.49,0.576 1.499,1.715 2.003,2.276c0.014,0.044 0.058,0.101 0.072,0.145c0.461,1.08 0.937,2.233 1.455,3.314c0.346,0.461 1.038,1.412 1.383,1.887c-2.637,-1.037 -4.64,1.758 -7.277,1.34c0.231,0.736 0.692,2.19 0.922,2.926c-0.692,0.086 -2.09,0.259 -2.781,0.345c-0.043,0.778 -0.159,2.335 -0.202,3.113c-2.32,0.663 -3.502,-1.715 -5.044,-2.897c0,0.98 0.014,2.954 0.014,3.949c1.888,0.619 3.775,1.282 5.677,1.902l-1.21,2.434c1.34,0.475 2.68,0.98 4.02,1.485c-1.787,1.801 -3.646,3.53 -5.663,5.058c-0.259,-1.037 -0.764,-3.097 -1.009,-4.136c-3.631,1.715 -1.297,3.747 0.504,5.491c-1.225,3.387 -2.479,-0.734 -3.66,-1.456l-1.441,0.072c-0.879,0.029 -2.637,0.101 -3.502,0.13c0.072,-0.764 0.245,-2.306 0.331,-3.084c0.447,-2.247 -0.403,-3.213 -2.551,-2.867c0.649,-0.993 1.311,-1.974 1.989,-2.954c-1.066,-1.946 -2.147,-3.876 -3.055,-5.893c-0.548,-0.837 -1.628,-2.508 -2.161,-3.344c-1.369,-0.863 -2.925,-1.426 -4.539,-1.498c-0.259,-0.029 -0.778,-0.072 -1.038,-0.101c-0.749,-0.576 -2.234,-1.729 -2.968,-2.306l0.288,-0.116c0.591,-0.201 1.772,-0.619 2.363,-0.835l0.072,-0.734c1.167,-1.931 2.824,-3.012 4.928,-3.214Zm1.023,1.009c0.85,0.922 0.85,0.922 0,0Zm-2.695,3.011c0.865,0.908 0.865,0.908 0,0Zm5.764,0.029c0.922,0.865 0.922,0.865 0,0Zm6.009,-0.101c0.865,0.923 0.865,0.923 0,0Zm-3.055,3.04c0.879,0.822 0.879,0.822 0,0Zm11.715,-0.316c0.461,0.389 0.461,0.389 0,0Zm-8.79,3.112c0.807,0.85 0.807,0.85 0,0Zm6.066,0.145c0.908,0.835 0.908,0.835 0,0Zm-6.009,5.663c0.793,0.792 0.793,0.792 0,0Zm2.94,2.708c4.813,0.375 -2.262,4.251 0,0Zm-3.055,2.941c4.784,0.187 -2.075,4.366 0,0Zm5.98,0.331c0.375,0.375 0.375,0.375 0,0Z" style="fill:#b9b9ba;fill-rule:nonzero;"/><path d="M449.887,627.7c0.853,0.853 0.853,0.853 0,0Z" style="fill:#aaa9ab;fill-rule:nonzero;"/><path d="M455.737,627.714c0.911,0.794 0.911,0.794 0,0Z" style="fill:#a3a1a2;fill-rule:nonzero;"/><path d="M503.42,630.321c5.202,-4.929 12.421,-1.773 18.646,-1.758c-2.234,1.542 -4.352,3.257 -6.441,4.971c-4.193,-0.533 -8.343,-1.469 -12.205,-3.213Z" style="fill:#7d7a7a;fill-rule:nonzero;"/><path d="M430.059,630.479c0.838,0.781 0.838,0.781 0,0Z" style="fill:#818284;fill-rule:nonzero;"/><path d="M473.186,630.336c0.824,0.75 0.824,0.75 0,0Z" style="fill:#0c0c0c;fill-rule:nonzero;"/><path d="M570.266,631.762c2.838,-0.36 5.678,-0.705 8.545,-1.052l0.505,-0.057c8.905,14.308 15.865,32.076 10.951,48.965l-0.503,-2.349c1.441,-12.017 -2.278,-23.733 -7.033,-34.598c-0.734,-1.685 -1.859,-3.156 -2.882,-4.639c0.145,1.557 0.288,3.112 0.461,4.683c-1.023,0.173 -3.084,0.518 -4.121,0.677c-1.772,-3.978 -3.717,-7.883 -5.922,-11.629Z" style="fill:#f16424;fill-rule:nonzero;"/><path d="M427.133,633.363c0.795,0.78 0.795,0.78 0,0Z" style="fill:#737476;fill-rule:nonzero;"/><path d="M424.109,635.856c4.741,0.592 -2.53,4.164 0,0Z" style="fill:#3b3c3d;fill-rule:nonzero;"/><path d="M438.473,636.273c0.26,0.029 0.781,0.072 1.041,0.101l-0.622,0.231l-0.419,-0.332Z" style="fill:#303032;fill-rule:nonzero;"/><path d="M450.205,636.055c0.882,0.825 0.882,0.825 0,0Z" style="fill:#19191a;fill-rule:nonzero;"/><path d="M536.044,637.771c0.057,1.628 0.114,3.242 0.202,4.9c0.764,0.345 2.291,1.037 3.068,1.397c-0.274,4.395 -0.402,8.804 -0.331,13.228c-0.894,0.562 -1.786,1.138 -2.666,1.729c0.634,0.475 1.902,1.441 2.55,1.931c2.623,4.423 6.153,8.213 9.9,11.729c-1.138,0.98 -2.263,1.975 -3.372,2.968c-0.778,-1.225 -1.542,-2.449 -2.306,-3.66c-1.397,0.216 -2.796,0.448 -4.193,0.692c-0.246,-0.475 -0.764,-1.412 -1.009,-1.873c-4.495,0.734 -9.553,0.648 -13.156,3.861c-2.997,2.032 -4.222,5.534 -5.447,8.762c0.13,-2.651 0.187,-5.346 0.231,-7.983c-1.83,2.84 -3.041,5.995 -3.919,9.237c2.017,4.654 4.15,9.51 8.487,12.493c7.018,3.401 14.856,2.465 22.206,0.822c0.303,0.259 0.907,0.764 1.21,1.023c-7.681,1.83 -16.225,3.343 -23.531,-0.619c-8.631,-4.828 -14.986,-16.399 -10.029,-25.895c0.403,-0.663 1.21,-1.988 1.628,-2.651c-1.023,-0.663 -2.046,-1.298 -3.055,-1.931c2.536,-3.084 5.966,-6.254 5.26,-10.664c0.461,-4.683 -3.098,-7.997 -6.383,-10.664c0.548,0 1.657,0.015 2.205,0.015c3.199,-0.043 6.47,-0.129 9.554,-1.109c2.954,-1.672 5.591,-3.862 8.689,-5.289c1.412,-0.778 2.824,-1.599 4.208,-2.449Zm-12.35,9.294c-0.461,1.441 -0.922,2.882 -1.383,4.322c1.931,-0.086 4.049,0.376 5.721,-0.863c0.418,-3.128 -1.845,-3.559 -4.337,-3.458Zm1.628,17.796c0.706,0.778 0.706,0.778 0,0Zm5.576,-0.187c4.323,-2.177 0.173,4.784 0,0Zm5.923,0.244c-0.288,2.276 3.04,4.367 5.144,4.02c0.086,-2.291 -2.968,-4.597 -5.144,-4.02Zm-14.454,2.954c0.749,0.778 0.749,0.778 0,0Zm19.598,1.067c0.98,0.808 0.98,0.808 0,0Zm-22.609,0.433c-1.138,1.426 -2.565,2.521 -4.251,3.257c-0.389,2.132 -3.285,7.334 0.245,7.507c1.196,-3.646 4.222,-6.427 5.548,-9.827l-1.542,-0.936Z" style="fill:#59585a;fill-rule:nonzero;"/><path d="M545.151,637.612c4.813,0.332 -2.328,4.235 0,0Z" style="fill:#1b1b1c;fill-rule:nonzero;"/><path d="M426.891,638.735c4.785,0.086 -2.11,4.452 0,0Z" style="fill:#999c9e;fill-rule:nonzero;"/><path d="M435.722,638.997c0.766,0.895 0.766,0.895 0,0Z" style="fill:#3c3d3e;fill-rule:nonzero;"/><path d="M467.741,639.139c0.867,0.809 0.867,0.809 0,0Z" style="fill:#9d9b9c;fill-rule:nonzero;"/><path d="M579.848,638.031c1.023,1.485 2.146,2.954 2.882,4.641c1.441,4.769 2.939,9.524 4.38,14.308c-2.594,0.433 -5.173,0.879 -7.752,1.34c-0.576,-5.072 -1.571,-10.071 -3.17,-14.928c1.037,-0.158 3.099,-0.503 4.121,-0.677c-0.173,-1.57 -0.317,-3.126 -0.461,-4.683Z" style="fill:#f38625;fill-rule:nonzero;"/><path d="M559.028,639.673c1.859,0.158 3.747,0.301 5.619,0.461c-0.173,1.902 -0.404,3.818 -0.705,5.72c-2.335,-0.029 -4.67,-0.057 -7.003,-0.116c0.677,-2.032 1.368,-4.063 2.089,-6.066Z" style="fill:#485e72;fill-rule:nonzero;"/><path d="M424.295,642.021c0.838,0.781 0.838,0.781 0,0Z" style="fill:#7a7c7e;fill-rule:nonzero;"/><path d="M464.902,641.69c0.853,0.867 0.853,0.867 0,0Z" style="fill:#a3a2a3;fill-rule:nonzero;"/><path d="M502.066,641.806c0.824,0.752 0.824,0.752 0,0Z" style="fill:#050506;fill-rule:nonzero;"/><path d="M540.091,649.775c1.514,-7.94 11.514,-8.371 16.846,-4.035c2.334,0.058 4.668,0.086 7.002,0.116c-0.402,1.527 -0.85,3.055 -1.353,4.567c-7.493,-0.461 -15.001,-0.648 -22.495,-0.648Z" style="fill:#526f8b;fill-rule:nonzero;"/><path d="M453.186,644.659c0.795,0.796 0.795,0.796 0,0Z" style="fill:#434446;fill-rule:nonzero;"/><path d="M461.213,644.631c4.235,-2.5 0.448,4.857 0,0Z" style="fill:#898889;fill-rule:nonzero;"/><path d="M476.113,653.333c0.867,0.781 0.867,0.781 0,0Z" style="fill:#898889;fill-rule:nonzero;"/><path d="M467.552,644.789c0.824,0.853 0.824,0.853 0,0Z" style="fill:#9b9a9b;fill-rule:nonzero;"/><path d="M479.008,644.818c0.853,0.737 0.853,0.737 0,0Z" style="fill:#767475;fill-rule:nonzero;"/><path d="M424.05,647.411c4.756,0.159 -2.125,4.408 0,0Z" style="fill:#525455;fill-rule:nonzero;"/><path d="M432.682,647.917c0.838,0.838 0.838,0.838 0,0Z" style="fill:#333334;fill-rule:nonzero;"/><path d="M475.955,647.484c4.785,0.26 -2.269,4.307 0,0Z" style="fill:#282829;fill-rule:nonzero;"/><path d="M523.693,647.065c2.493,-0.101 4.755,0.331 4.337,3.458c-1.671,1.239 -3.79,0.778 -5.721,0.865c0.461,-1.441 0.922,-2.882 1.383,-4.323Z" style="fill:#151718;fill-rule:nonzero;"/><path d="M426.962,650.294c4.741,-0.029 -1.937,4.538 0,0Z" style="fill:#939697;fill-rule:nonzero;"/><path d="M435.506,650.307c4.727,0.39 -2.399,4.294 0,0Z" style="fill:#5b5d5e;fill-rule:nonzero;"/><path d="M447.28,650.064c2.154,-0.348 3.007,0.62 2.559,2.875c-2.645,0.376 -3.498,-0.578 -2.559,-2.875Z" style="fill:#0e0f10;fill-rule:nonzero;"/><path d="M453.072,650.307c4.799,0.189 -2.081,4.38 0,0Z" style="fill:#5b5c5d;fill-rule:nonzero;"/><path d="M441.544,664.921c0.766,0.765 0.766,0.765 0,0Z" style="fill:#5b5c5d;fill-rule:nonzero;"/><path d="M467.496,650.423c0.838,0.796 0.838,0.796 0,0Z" style="fill:#878789;fill-rule:nonzero;"/><path d="M473.274,650.496c0.867,0.781 0.867,0.781 0,0Z" style="fill:#89898b;fill-rule:nonzero;"/><path d="M478.981,650.452c0.896,0.781 0.896,0.781 0,0Z" style="fill:#989597;fill-rule:nonzero;"/><path d="M484.903,650.739c0.853,0.796 0.853,0.796 0,0Z" style="fill:#908d8f;fill-rule:nonzero;"/><path d="M424.095,653.204c4.756,0.519 -2.486,4.192 0,0Z" style="fill:#2b2c2d;fill-rule:nonzero;"/><path d="M444.268,653.233c4.77,0.043 -2.024,4.452 0,0Z" style="fill:#6b6c6d;fill-rule:nonzero;"/><path d="M470.435,653.362c0.766,0.81 0.766,0.81 0,0Z" style="fill:#6c6c6e;fill-rule:nonzero;"/><path d="M426.92,656.057c4.77,0.216 -2.212,4.351 0,0Z" style="fill:#424244;fill-rule:nonzero;"/><path d="M461.674,656.188c0.81,0.823 0.81,0.823 0,0Z" style="fill:#848487;fill-rule:nonzero;"/><path d="M467.538,656.287c0.824,0.651 0.824,0.651 0,0Z" style="fill:#414142;fill-rule:nonzero;"/><path d="M473.318,656.202c0.752,0.78 0.752,0.78 0,0Z" style="fill:#575658;fill-rule:nonzero;"/><path d="M479.008,656.214c0.867,0.766 0.867,0.766 0,0Z" style="fill:#878688;fill-rule:nonzero;"/><path d="M490.566,656.548c0.275,0.044 0.838,0.145 1.113,0.187l-0.593,0.491l-0.52,-0.679Z" style="fill:#8d8a8c;fill-rule:nonzero;"/><path d="M569.964,657.758c1.801,1.743 1.902,7.651 -0.072,9.28c-1.052,-2.867 -0.951,-6.427 0.072,-9.28Z" style="fill:#4b4c4f;fill-rule:nonzero;"/><path d="M579.359,658.32c2.579,-0.462 5.159,-0.908 7.752,-1.34c1.21,4.265 1.656,8.703 1.498,13.141c-3.559,0.073 -7.119,0.159 -10.677,0.26c0.821,-3.978 1.254,-8.013 1.426,-12.061Z" style="fill:#f6a927;fill-rule:nonzero;"/><path d="M429.816,658.952c4.756,0.101 -2.11,4.409 0,0Z" style="fill:#484a4b;fill-rule:nonzero;"/><path d="M453.015,658.895c4.77,0.232 -2.197,4.409 0,0Z" style="fill:#979799;fill-rule:nonzero;"/><path d="M458.791,659.155c0.81,0.794 0.81,0.794 0,0Z" style="fill:#808082;fill-rule:nonzero;"/><path d="M476.186,659.17c0.795,0.737 0.795,0.737 0,0Z" style="fill:#565657;fill-rule:nonzero;"/><path d="M481.933,659.04c0.824,0.781 0.824,0.781 0,0Z" style="fill:#787779;fill-rule:nonzero;"/><path d="M487.813,659.097c0.781,0.838 0.781,0.838 0,0Z" style="fill:#868586;fill-rule:nonzero;"/><path d="M493.477,659.097l0.578,0.072l0.058,0.491l-0.506,0.29l-0.13,-0.853Z" style="fill:#868486;fill-rule:nonzero;"/><path d="M433.013,662.095c0.737,0.796 0.737,0.796 0,0Z" style="fill:#5b5c5e;fill-rule:nonzero;"/><path d="M455.925,662.008c0.795,0.737 0.795,0.737 0,0Z" style="fill:#585858;fill-rule:nonzero;"/><path d="M473.301,662.022c0.723,0.766 0.723,0.766 0,0Z" style="fill:#373838;fill-rule:nonzero;"/><path d="M479.111,661.994c0.679,0.825 0.679,0.825 0,0Z" style="fill:#4b4b4c;fill-rule:nonzero;"/><path d="M484.903,661.994c0.737,0.796 0.737,0.796 0,0Z" style="fill:#585859;fill-rule:nonzero;"/><path d="M491.416,662.775c0.029,-0.187 0.072,-0.549 0.101,-0.737l-0.101,0.737Z" style="fill:#777678;fill-rule:nonzero;"/><path d="M435.78,665.063c0.781,0.693 0.781,0.693 0,0Z" style="fill:#353637;fill-rule:nonzero;"/><path d="M453.1,664.921c0.766,0.723 0.766,0.723 0,0Z" style="fill:#39393b;fill-rule:nonzero;"/><path d="M481.919,664.877c0.708,0.794 0.708,0.794 0,0Z" style="fill:#4a4a4b;fill-rule:nonzero;"/><path d="M487.784,664.848c0.766,0.752 0.766,0.752 0,0Z" style="fill:#535253;fill-rule:nonzero;"/><path d="M525.323,664.862c0.708,0.78 0.708,0.78 0,0Z" style="fill:#1e1e1e;fill-rule:nonzero;"/><path d="M530.899,664.675c4.336,-2.183 0.173,4.799 0,0Z" style="fill:#282728;fill-rule:nonzero;"/><path d="M505.135,670.625c0.752,0.752 0.752,0.752 0,0Z" style="fill:#464546;fill-rule:nonzero;"/><path d="M519.357,669.37l1.542,0.938c-1.326,3.4 -4.352,6.181 -5.548,9.827c-3.53,-0.173 -0.634,-5.375 -0.245,-7.508c1.686,-0.734 3.113,-1.829 4.251,-3.257Z" style="fill:#0a0a0b;fill-rule:nonzero;"/><path d="M546.087,684.717c0.792,-8.718 8.703,-15.879 17.55,-15.231c4.971,-0.173 8.733,3.33 11.571,6.974c0.908,-2.031 1.817,-4.063 2.724,-6.08c3.559,-0.101 7.119,-0.187 10.678,-0.259c-0.361,3.358 -0.879,6.715 -1.773,9.972c-13.675,0.547 -27.162,3.169 -40.75,4.625Z" style="fill:#f8c125;fill-rule:nonzero;"/><path d="M577.918,697.499c6.44,-4.957 9.135,-12.911 11.844,-20.232l0.505,2.349c-1.542,7.969 -6.168,15.606 -12.767,20.447c-9.452,6.845 -24.597,3.704 -29.8,-7.017c11.644,-1.801 23.387,-3.141 35.131,-4.222c-3.846,6.066 -10.172,11.312 -17.724,10.98c-4.913,0.274 -9.092,-2.478 -13.127,-4.841c6.111,7.709 18.46,8.876 25.939,2.535Z" style="fill:#f8eb25;fill-rule:nonzero;"/><path d="M546.087,684.718c13.588,-1.456 27.076,-4.079 40.75,-4.626c-1.081,3.011 -2.421,5.951 -4.006,8.733c-11.743,1.08 -23.488,2.42 -35.131,4.222c-0.663,-2.753 -1.167,-5.548 -1.614,-8.329Z" style="fill:#f8d623;fill-rule:nonzero;"/><path d="M845.634,405.489c19.716,-0.47 39.447,0.059 59.178,-0.279c0.147,4.216 0.177,8.433 0.162,12.65c-7.552,0.015 -15.104,0 -22.641,0.015c-0.088,19.863 0.029,39.727 -0.044,59.59c-4.613,-0.044 -9.197,-0.029 -13.781,0.073c-0.352,-19.878 -0.058,-39.771 -0.162,-59.663c-7.536,-0.015 -15.073,-0.015 -22.61,0c-0.029,-4.128 -0.058,-8.257 -0.102,-12.385Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M912.157,405.24c4.216,0.059 8.447,0.088 12.678,0.088c-0.073,9.021 0.044,18.027 -0.088,27.033c9.27,-9.976 29.074,-8.83 34.261,4.775c4.421,12.855 1.322,26.828 2.307,40.168c-4.188,-0.015 -8.507,0.749 -12.605,-0.294c-0.545,-9.903 0.425,-19.849 -0.441,-29.722c-0.501,-4.907 -4.452,-9.388 -9.565,-9.491c-7.257,-1.146 -13.766,5.392 -13.781,12.473c-0.323,9.05 0.029,18.1 -0.146,27.165c-4.305,0.06 -8.596,0.031 -12.886,-0.146c0.25,-24.007 -0.279,-48.042 0.266,-72.049Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M981.794,428.57c9.946,-5.348 23.405,-5.069 32.411,2.086c7.477,6.244 8.58,16.705 8.139,25.799c-13.267,0.22 -26.534,0.059 -39.785,0.088c0.793,3.144 2.276,6.317 5.23,7.977c7.228,4.394 16.763,2.733 23.432,-1.998c2.719,2.571 5.569,5.024 7.949,7.933c-10.314,10.094 -28.371,11.093 -40.094,2.865c-14.016,-10.696 -12.988,-36.068 2.717,-44.751Zm0.823,17.145c9.123,0.309 18.262,0.162 27.4,0.103c-2.806,-13.12 -23.595,-12.135 -27.4,-0.103Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M778.768,460.819c9.593,0.015 19.188,-0.029 28.781,0.029c20.76,46.514 41.696,92.94 62.484,139.425c-9.579,0.058 -19.143,-0.073 -28.708,0.088c-4.113,-8.711 -7.991,-17.526 -11.811,-26.341c-24.227,-0.177 -48.453,-0.045 -72.68,-0.074c-3.953,8.771 -7.993,17.513 -11.916,26.299c-9.579,0.088 -19.157,0.029 -28.737,0.029c20.877,-46.471 41.696,-92.985 62.588,-139.454Zm14.207,30.118c-8.594,19.436 -17.014,38.963 -25.55,58.429c17.13,0 34.246,0.015 51.363,-0.015c-8.683,-19.437 -16.647,-39.214 -25.814,-58.415Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M874.31,501.94c8.153,0.029 16.307,0.015 24.476,0.015c0,17.014 0,34.013 -0.015,51.025c-0.104,7.155 1.777,14.721 6.861,20.011c6.728,7.007 18.027,8.285 26.694,4.407c10.329,-4.598 15.603,-16.396 14.956,-27.283c-0.029,-16.057 0,-32.101 -0.015,-48.16c8.037,-0.015 16.073,0.045 24.11,-0.029c0.175,32.821 0.029,65.644 0.073,98.465c-7.258,0 -14.53,-0.015 -21.788,0c-0.572,-4.348 -1.086,-8.698 -1.484,-13.061c-7.464,7.038 -16.485,13.149 -26.96,14.104c-14.06,2.116 -29.486,-3.202 -37.698,-15.146c-8.478,-11.607 -9.726,-26.548 -9.212,-40.462c0,-14.618 -0.015,-29.251 0,-43.885Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M1070.255,461.171c8.183,-0.058 16.381,-0.175 24.579,0.074c-0.147,46.352 -0.029,92.705 -0.073,139.042c-7.67,-0.102 -15.353,0.266 -23.007,-0.25c-0.397,-4.334 -0.94,-8.667 -1.308,-13.001c-5.127,7.242 -12.811,12.635 -21.582,14.339c-16.101,3.071 -34.113,-0.822 -46.088,-12.429c-9.329,-8.889 -14.001,-21.788 -14.486,-34.497c-0.66,-13.384 2.087,-27.606 10.77,-38.183c8.432,-10.535 21.934,-15.927 35.187,-16.514c13.398,-0.808 27.87,4.099 36.2,15.044c-0.381,-17.88 -0.015,-35.744 -0.19,-53.625Zm-36.686,62.059c-6.318,1.44 -12.179,5.127 -15.853,10.504c-8.241,12.033 -6.508,30.412 4.981,39.815c10.094,8.447 26.107,8.139 36.098,-0.309c12.135,-9.887 13.252,-30.029 2.763,-41.489c-6.832,-7.728 -18.042,-10.858 -27.988,-8.521Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M1115.565,501.779c8.14,-0.235 16.308,-0.073 24.462,-0.117l0,98.626c-8.153,-0.058 -16.292,0.133 -24.417,-0.131c0.029,-32.792 0.117,-65.585 -0.045,-98.377Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M1121.661,461.421c9.27,-4.481 21.817,2.894 20.613,13.649c0.381,10.666 -12.532,16.968 -21.392,12.135c-10.328,-4.599 -9.901,-21.847 0.78,-25.785Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M1198.057,501.236c16.558,-3.452 35.172,0.22 47.675,12.062c19.261,17.836 20.921,50.569 4.848,70.976c-17.395,22.038 -53.565,24.11 -74.575,6.2c-17.777,-15.499 -21.303,-43.385 -11.004,-64.042c6.346,-12.943 18.967,-22.39 33.056,-25.196Zm4.569,22.067c-21.45,6.098 -25.196,38.478 -8.33,51.539c10.49,7.905 27.268,6.686 35.79,-3.614c9.872,-12.048 8.859,-31.719 -2.719,-42.341c-6.493,-6.127 -16.249,-8.037 -24.741,-5.584Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M1277.937,460.73c19.173,0.102 38.361,-0.147 57.549,0.117c17.923,0.279 36.304,6.626 48.659,20.01c15.837,16.911 20.965,41.798 17.087,64.219c-2.91,16.939 -11.931,33.189 -26.226,43.076c-11.915,8.419 -26.68,11.96 -41.122,12.106c-18.674,0.235 -37.346,0.06 -56.005,0.089c0.015,-46.544 -0.102,-93.073 0.058,-139.616Zm26.592,25.152c-0.294,29.618 -0.308,59.251 0.015,88.856c10.271,-0.177 20.554,0.058 30.838,-0.104c12.62,-0.294 25.593,-5.832 32.777,-16.542c9.917,-14.707 10.431,-34.835 2.675,-50.584c-5.54,-11.475 -17.19,-19.321 -29.752,-20.921c-12.121,-1.425 -24.373,-0.251 -36.553,-0.705Z" style="fill:#6599cd;fill-rule:nonzero;"/><path d="M1418.773,460.701c22.2,0.133 44.399,-0.044 66.599,0.089c12.516,0.175 25.872,2.688 35.569,11.195c10.298,8.903 13.604,23.83 11.033,36.818c-1.866,8.403 -8.228,14.854 -15.588,18.849c1.161,2.322 4.423,2.646 6.347,4.306c18.834,13.03 18.865,44.383 0.838,58.12c-11.269,8.551 -26.005,10.065 -39.698,10.24c-21.728,0.104 -43.444,0.045 -65.172,0.031c0.015,-46.544 -0.133,-93.103 0.073,-139.647Zm25.799,55.697c14.501,0.337 29.016,0.191 43.531,0.102c7.552,-0.029 16.91,-3.555 18.174,-12.017c1.866,-10.564 -8.374,-19.159 -18.291,-19.35c-14.355,-0.514 -28.737,0.104 -43.092,-0.294c-0.602,10.504 -0.058,21.039 -0.323,31.558Zm0.044,23.757c0.177,11.856 -0.308,23.727 0.264,35.569c13.443,-0.308 26.9,0.044 40.344,-0.162c7.508,-0.337 15.749,-1.527 21.435,-6.92c5.113,-4.745 5.245,-13.311 0.896,-18.599c-5.642,-6.965 -14.971,-10.065 -23.713,-9.932c-13.076,0.029 -26.151,-0.015 -39.227,0.044Z" style="fill:#6599cd;fill-rule:nonzero;"/></svg>
\ No newline at end of file diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs index efac93f94e..1681f0334d 100644 --- a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; @@ -11,7 +12,7 @@ namespace MediaBrowser.Providers.Plugins.ListenBrainz; /// <summary> /// ListenBrainz plugin instance. /// </summary> -public class ListenBrainzPlugin : BasePlugin<PluginConfiguration>, IHasWebPages +public class ListenBrainzPlugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage { /// <summary> /// Initializes a new instance of the <see cref="ListenBrainzPlugin"/> class. @@ -42,6 +43,9 @@ public class ListenBrainzPlugin : BasePlugin<PluginConfiguration>, IHasWebPages public override string ConfigurationFileName => "Jellyfin.Plugin.ListenBrainz.xml"; /// <inheritdoc /> + public string ImageResourceName => GetType().Namespace + ".Configuration.ListenBrainz_logo.svg"; + + /// <inheritdoc /> public IEnumerable<PluginPageInfo> GetPages() { var resourcePrefix = GetType().Namespace + ".Configuration."; @@ -52,11 +56,6 @@ public class ListenBrainzPlugin : BasePlugin<PluginConfiguration>, IHasWebPages }; yield return new PluginPageInfo { - Name = Name + "Logo", - EmbeddedResourcePath = resourcePrefix + "ListenBrainz_logo.svg" - }; - yield return new PluginPageInfo - { Name = Name + "Notice", EmbeddedResourcePath = resourcePrefix + "NOTICE.md" }; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalUrlProvider.cs index dd0a939f72..f7c570692d 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalUrlProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalUrlProvider.cs @@ -19,7 +19,7 @@ public class MusicBrainzReleaseGroupExternalUrlProvider : IExternalUrlProvider { if (item is MusicAlbum) { - if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var externalId)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out var externalId)) { yield return Plugin.Instance!.Configuration.Server + $"/release-group/{externalId}"; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackExternalUrlProvider.cs index 59e6f42b19..c2bbd8ba86 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackExternalUrlProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackExternalUrlProvider.cs @@ -19,7 +19,7 @@ public class MusicBrainzTrackExternalUrlProvider : IExternalUrlProvider { if (item is Audio) { - if (item.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out var externalId)) + if (item.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out var externalId)) { yield return Plugin.Instance!.Configuration.Server + $"/track/{externalId}"; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index 69225d0b95..f448e6b20c 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -6,6 +6,7 @@ using System.Threading; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; @@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// <summary> /// Plugin instance. /// </summary> -public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IDisposable +public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage, IDisposable { private readonly ILogger<Plugin> _logger; private readonly Lock _queryLock = new(); @@ -66,6 +67,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IDisposable // TODO remove when plugin removed from server. public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; + /// <inheritdoc /> + public string ImageResourceName => GetType().Namespace + ".jellyfin-plugin-musicbrainz.svg"; + /// <summary> /// Gets the current MusicBrainz query client. /// </summary> diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/jellyfin-plugin-musicbrainz.svg b/MediaBrowser.Providers/Plugins/MusicBrainz/jellyfin-plugin-musicbrainz.svg new file mode 100644 index 0000000000..8074d59d7b --- /dev/null +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/jellyfin-plugin-musicbrainz.svg @@ -0,0 +1,36 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 508 285.75" version="1.1" id="svg8"> + <defs id="defs2"> + <linearGradient id="linearGradient851"> + <stop style="stop-color:#fffedb;stop-opacity:1" offset="0" id="stop847"/> + <stop style="stop-color:#ffeec2;stop-opacity:1" offset="1" id="stop849"/> + </linearGradient> + <linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse" gradientTransform="translate(95.25 74.882) scale(1.66894)"> + <stop offset="0" stop-color="#90cea1" id="stop916"/> + <stop offset=".56" stop-color="#3cbec9" id="stop918"/> + <stop offset="1" stop-color="#00b3e5" id="stop920"/> + </linearGradient> + <style id="style914"/> + <style id="style1359"> + .a{fill:#ba478f}.b{fill:#eb743b} + </style> + <radialGradient xlink:href="#linearGradient851" id="radialGradient853" cx="125.255" cy="16.735" fx="125.255" fy="16.735" r="254" gradientTransform="matrix(0 1.06475 -1.74952 0 154.532 -61.444)" gradientUnits="userSpaceOnUse"/> + </defs> + <g id="layer1"> + <path style="opacity:.98;fill:url(#radialGradient853);stroke-width:2.64583;fill-opacity:1" id="rect845" d="M0 0h508v285.75H0z"/> + <g id="g1409" transform="translate(95.25 119.153) scale(1.69442)"> + <path class="a" id="polygon1363" d="M0 7v14l12 7V0z"/> + <path class="b" id="polygon1365" d="M25 7v14l-12 7V0z"/> + <path class="a" d="m40.2 5.52 4.44 13.85 4.43-13.85h6.32v19.91h-4.82v-4.65l.43-9.51-4.77 14.16h-3.18l-4.82-14.18.46 9.53v4.65h-4.8V5.52h6.3z" transform="translate(-2 -1)" id="path1367"/> + <path class="a" d="M67 23.83a4.9 4.9 0 0 1-1.68 1.38 5 5 0 0 1-2.27.49 6.22 6.22 0 0 1-2-.31 3.89 3.89 0 0 1-1.56-1 4.51 4.51 0 0 1-1-1.71 7.7 7.7 0 0 1-.36-2.51v-9.53h4.61v9.6a2 2 0 0 0 .47 1.45 1.88 1.88 0 0 0 1.37.46 2.87 2.87 0 0 0 1.4-.3 2.24 2.24 0 0 0 .88-.85V10.64h4.63v14.79h-4.32z" transform="translate(-2 -1)" id="path1369"/> + <path class="a" d="M81.76 21.27a1 1 0 0 0-.13-.51 1.3 1.3 0 0 0-.46-.42 4.21 4.21 0 0 0-.89-.34q-.56-.18-1.42-.37a12.89 12.89 0 0 1-2-.61 6.57 6.57 0 0 1-1.65-.93 4.14 4.14 0 0 1-1.11-1.31 3.59 3.59 0 0 1-.4-1.72 4.12 4.12 0 0 1 .4-1.79 4.34 4.34 0 0 1 1.18-1.49 5.88 5.88 0 0 1 1.91-1 8.2 8.2 0 0 1 2.58-.38 9.48 9.48 0 0 1 2.69.36 6.34 6.34 0 0 1 2 1 4.41 4.41 0 0 1 1.29 1.52 4.21 4.21 0 0 1 .45 1.95h-4.59a1.88 1.88 0 0 0-.42-1.31 1.89 1.89 0 0 0-1.45-.46 2.08 2.08 0 0 0-.66.1 1.75 1.75 0 0 0-.54.29 1.38 1.38 0 0 0-.37.44 1.22 1.22 0 0 0-.14.57 1.14 1.14 0 0 0 .58 1 5.41 5.41 0 0 0 1.88.63 18.45 18.45 0 0 1 2.15.53 6.79 6.79 0 0 1 1.83.86 4.15 4.15 0 0 1 1.26 1.35 3.87 3.87 0 0 1 .47 2 3.76 3.76 0 0 1-.45 1.77 4.31 4.31 0 0 1-1.29 1.44 6.61 6.61 0 0 1-2 1 9.52 9.52 0 0 1-2.68.35 8.14 8.14 0 0 1-2.82-.45 6.56 6.56 0 0 1-2.05-1.17 4.92 4.92 0 0 1-1.25-1.61 4.14 4.14 0 0 1-.42-1.78h4.31a1.78 1.78 0 0 0 .68 1.5 2.81 2.81 0 0 0 1.68.47 2.21 2.21 0 0 0 1.42-.38 1.22 1.22 0 0 0 .43-1.1z" transform="translate(-2 -1)" id="path1371"/> + <path class="a" d="M88.32 6.82a2.17 2.17 0 0 1 .18-.9 2.07 2.07 0 0 1 .5-.71 2.44 2.44 0 0 1 .81-.46 3.37 3.37 0 0 1 2.08 0 2.44 2.44 0 0 1 .81.46 2.07 2.07 0 0 1 .53.71 2.3 2.3 0 0 1 0 1.8 2.07 2.07 0 0 1-.53.71 2.44 2.44 0 0 1-.81.46 3.37 3.37 0 0 1-2.08 0 2.44 2.44 0 0 1-.81-.45 2.07 2.07 0 0 1-.53-.71 2.17 2.17 0 0 1-.15-.91zm4.89 18.61H88.6V10.64h4.62v14.79z" transform="translate(-2 -1)" id="path1373"/> + <path class="a" d="M102.31 22.15a2.05 2.05 0 0 0 1.5-.53 1.93 1.93 0 0 0 .52-1.47h4.32a5.35 5.35 0 0 1-.47 2.28 5.22 5.22 0 0 1-1.31 1.75 5.88 5.88 0 0 1-2 1.13 8.13 8.13 0 0 1-5.51-.18 6 6 0 0 1-2.17-1.58 6.7 6.7 0 0 1-1.31-2.38 9.74 9.74 0 0 1-.44-3v-.29a9.81 9.81 0 0 1 .44-3 6.75 6.75 0 0 1 1.3-2.39 6 6 0 0 1 2.15-1.58 7.37 7.37 0 0 1 3-.57 7.81 7.81 0 0 1 2.55.4 5.57 5.57 0 0 1 2 1.16 5.18 5.18 0 0 1 1.29 1.87 6.53 6.53 0 0 1 .46 2.52h-4.32a3.62 3.62 0 0 0-.12-.93 2 2 0 0 0-.38-.76 1.83 1.83 0 0 0-.65-.51 2.14 2.14 0 0 0-.92-.18 1.87 1.87 0 0 0-1.14.32 2.07 2.07 0 0 0-.66.86 4.3 4.3 0 0 0-.31 1.26 14.81 14.81 0 0 0-.08 1.52v.33a14.89 14.89 0 0 0 .08 1.54 4.3 4.3 0 0 0 .31 1.26 1.93 1.93 0 0 0 .68.85 2 2 0 0 0 1.19.3z" transform="translate(-2 -1)" id="path1375"/> + <path class="b" d="M110.81 25.43V5.52H118a14.77 14.77 0 0 1 3.3.33 7.29 7.29 0 0 1 2.47 1 4.6 4.6 0 0 1 1.54 1.72 5.22 5.22 0 0 1 .53 2.43 5.69 5.69 0 0 1-.15 1.32 4.21 4.21 0 0 1-.49 1.2 3.92 3.92 0 0 1-.87 1 4.45 4.45 0 0 1-1.3.73 4.56 4.56 0 0 1 1.5.67 3.89 3.89 0 0 1 1 1 4 4 0 0 1 .55 1.24 5.36 5.36 0 0 1 .17 1.35 5.26 5.26 0 0 1-1.9 4.49 9 9 0 0 1-5.59 1.47h-7.94zm4.8-11.61h2.5a3.56 3.56 0 0 0 2.24-.57 2 2 0 0 0 .67-1.65 2.14 2.14 0 0 0-.72-1.81 3.89 3.89 0 0 0-2.3-.56h-2.35v4.59zm0 3.14v4.77h3.14a3.8 3.8 0 0 0 1.24-.18 2.25 2.25 0 0 0 .83-.49 1.88 1.88 0 0 0 .47-.72 2.55 2.55 0 0 0 .15-.89 3.69 3.69 0 0 0-.14-1 1.92 1.92 0 0 0-.44-.79 2 2 0 0 0-.78-.5 3.36 3.36 0 0 0-1.16-.18h-3.32z" transform="translate(-2 -1)" id="path1377"/> + <path class="b" d="M137.61 14.81h-1.52a4.09 4.09 0 0 0-1.79.33 2.06 2.06 0 0 0-1 1v9.37h-4.6V10.64h4.3l.15 1.9a4.51 4.51 0 0 1 1.36-1.6 3.18 3.18 0 0 1 1.88-.57 5.57 5.57 0 0 1 .68 0 3.68 3.68 0 0 1 .61.12z" transform="translate(-2 -1)" id="path1379"/> + <path class="b" d="M147.19 25.43a3.19 3.19 0 0 1-.25-.61q-.1-.34-.18-.72a4.24 4.24 0 0 1-3.55 1.6 5.66 5.66 0 0 1-1.93-.33 5.11 5.11 0 0 1-1.59-.91 4.31 4.31 0 0 1-1.09-1.4 4 4 0 0 1-.4-1.8 4.83 4.83 0 0 1 .42-2.05 3.89 3.89 0 0 1 1.27-1.52 6.3 6.3 0 0 1 2.16-1 12.56 12.56 0 0 1 3.1-.33h1.42v-.75a2.43 2.43 0 0 0-.41-1.49 1.58 1.58 0 0 0-1.35-.55 1.71 1.71 0 0 0-1.22.4 1.59 1.59 0 0 0-.42 1.22h-4.61a4.11 4.11 0 0 1 .46-1.91 4.49 4.49 0 0 1 1.31-1.53 6.54 6.54 0 0 1 2-1 9 9 0 0 1 2.67-.37 8.76 8.76 0 0 1 2.45.33 5.5 5.5 0 0 1 1.95 1 4.61 4.61 0 0 1 1.29 1.65 5.38 5.38 0 0 1 .46 2.3v7.3a7.71 7.71 0 0 0 .12.94 5 5 0 0 0 .2.72 4.26 4.26 0 0 0 .27.59v.23h-4.61zm-2.88-3a2.57 2.57 0 0 0 1.43-.37 2.32 2.32 0 0 0 .81-.83v-2.38h-1.45a2.94 2.94 0 0 0-1.1.15 1.85 1.85 0 0 0-.71.48 1.8 1.8 0 0 0-.38.69 2.84 2.84 0 0 0-.12.81 1.32 1.32 0 0 0 .42 1 1.53 1.53 0 0 0 1.1.44z" transform="translate(-2 -1)" id="path1381"/> + <path class="b" d="M153.69 6.82a2.17 2.17 0 0 1 .18-.9 2.07 2.07 0 0 1 .53-.71 2.44 2.44 0 0 1 .81-.46 3.37 3.37 0 0 1 2.08 0 2.44 2.44 0 0 1 .81.46 2.07 2.07 0 0 1 .53.71 2.3 2.3 0 0 1 0 1.8 2.07 2.07 0 0 1-.53.71 2.44 2.44 0 0 1-.81.46 3.37 3.37 0 0 1-2.08 0 2.44 2.44 0 0 1-.81-.46 2.07 2.07 0 0 1-.53-.71 2.17 2.17 0 0 1-.18-.9zm4.89 18.61H154V10.64h4.62v14.79z" transform="translate(-2 -1)" id="path1383"/> + <path class="b" d="m165.65 10.64.15 1.74a5 5 0 0 1 1.83-1.5 5.5 5.5 0 0 1 2.4-.51 5.74 5.74 0 0 1 1.88.29 3.5 3.5 0 0 1 1.47 1 4.59 4.59 0 0 1 1 1.78 9.43 9.43 0 0 1 .33 2.71v9.31H170v-9.35a3.48 3.48 0 0 0-.1-1.11 1.58 1.58 0 0 0-.41-.67 1.41 1.41 0 0 0-.66-.33 4 4 0 0 0-.88-.09 2.37 2.37 0 0 0-1.22.29 2.26 2.26 0 0 0-.79.78v10.45h-4.61V10.64z" transform="translate(-2 -1)" id="path1385"/> + <path class="b" d="M182.64 21.88h6.74v3.55h-12.56v-2.57l6.56-8.67h-6.28v-3.55h12.13v2.49z" transform="translate(-2 -1)" id="path1387"/> + </g> + </g> +</svg> diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 4882822766..f562d64ddd 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -413,7 +413,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } item.Overview = result.Plot; - item.OriginalLanguage = result.Language; + item.OriginalLanguage = result.Language?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); if (!Plugin.Instance.Configuration.CastAndCrew) { diff --git a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs index a0fba48f05..9066ff8523 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/Plugin.cs @@ -5,12 +5,13 @@ using System; using System.Collections.Generic; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { - public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage { public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) @@ -29,6 +30,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb // TODO remove when plugin removed from server. public override string ConfigurationFileName => "Jellyfin.Plugin.Omdb.xml"; + public string ImageResourceName => GetType().Namespace + ".jellyfin-plugin-omdb.png"; + public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/Omdb/jellyfin-plugin-omdb.png b/MediaBrowser.Providers/Plugins/Omdb/jellyfin-plugin-omdb.png Binary files differnew file mode 100644 index 0000000000..a4bb7f7d5f --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Omdb/jellyfin-plugin-omdb.png diff --git a/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs b/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs index 28f8c0c617..167959967d 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using MediaBrowser.Providers.Plugins.StudioImages.Configuration; @@ -13,7 +14,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages /// <summary> /// Artwork Plugin class. /// </summary> - public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage { /// <summary> /// Artwork repository URL. @@ -51,6 +52,9 @@ namespace MediaBrowser.Providers.Plugins.StudioImages public override string ConfigurationFileName => "Jellyfin.Plugin.StudioImages.xml"; /// <inheritdoc/> + public string ImageResourceName => GetType().Namespace + ".jellyfin-plugin-studioimages.svg"; + + /// <inheritdoc/> public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/StudioImages/jellyfin-plugin-studioimages.svg b/MediaBrowser.Providers/Plugins/StudioImages/jellyfin-plugin-studioimages.svg new file mode 100644 index 0000000000..e9da69c571 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/StudioImages/jellyfin-plugin-studioimages.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1920 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect x="0" y="0" width="1920" height="1080" style="fill:url(#_Radial1);fill-rule:nonzero;"/><g><path d="M487.844,725.411c-10.779,0 -20.007,-3.838 -27.683,-11.514c-7.676,-7.676 -11.514,-16.904 -11.514,-27.683l0,-274.381c0,-10.779 3.838,-20.007 11.514,-27.683c7.676,-7.676 16.904,-11.514 27.683,-11.514l274.381,0c10.779,0 20.007,3.838 27.683,11.514c7.676,7.676 11.514,16.904 11.514,27.683l0,274.381c0,10.779 -3.838,20.007 -11.514,27.683c-7.676,7.676 -16.904,11.514 -27.683,11.514l-274.381,0Zm0,-39.197l274.381,0l0,-274.381l-274.381,0l0,274.381Zm19.599,-39.197l235.184,0l-73.495,-97.993l-58.796,78.395l-44.097,-58.796l-58.796,78.395Zm-19.599,39.197l0,-274.381l0,274.381Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M934.841,493.37c0,6.419 -1.632,12.505 -4.897,18.26c-3.265,5.755 -8.3,10.43 -15.106,14.027c-6.806,3.597 -15.632,5.395 -26.477,5.395c-5.423,0 -10.154,-0.249 -14.193,-0.747c-4.039,-0.498 -7.83,-1.3 -11.371,-2.407c-3.541,-1.107 -7.193,-2.545 -10.956,-4.316l0,-28.552c6.419,3.209 12.893,5.672 19.422,7.387c6.529,1.715 12.45,2.573 17.762,2.573c4.759,0 8.245,-0.83 10.458,-2.49c2.213,-1.66 3.32,-3.763 3.32,-6.308c0,-3.099 -1.632,-5.616 -4.897,-7.553c-3.265,-1.937 -8.77,-4.62 -16.517,-8.051c-5.865,-2.767 -10.956,-5.672 -15.272,-8.715c-4.316,-3.043 -7.636,-6.778 -9.96,-11.205c-2.324,-4.427 -3.486,-10.015 -3.486,-16.766c0,-7.636 1.881,-14.027 5.644,-19.173c3.763,-5.146 8.992,-9.019 15.687,-11.62c6.695,-2.601 14.47,-3.901 23.323,-3.901c7.747,0 14.719,0.858 20.916,2.573c6.197,1.715 11.731,3.68 16.6,5.893l-9.794,24.734c-5.091,-2.324 -10.098,-4.178 -15.023,-5.561c-4.925,-1.383 -9.49,-2.075 -13.695,-2.075c-4.095,0 -7.11,0.719 -9.047,2.158c-1.937,1.439 -2.905,3.265 -2.905,5.478c0,1.881 0.719,3.541 2.158,4.98c1.439,1.439 3.846,3.016 7.221,4.731c3.375,1.715 7.996,3.901 13.861,6.557c5.755,2.545 10.652,5.34 14.691,8.383c4.039,3.043 7.138,6.64 9.296,10.79c2.158,4.15 3.237,9.324 3.237,15.521Zm60.59,12.616c2.877,0 5.506,-0.304 7.885,-0.913c2.379,-0.609 4.842,-1.356 7.387,-2.241l0,23.074c-3.431,1.439 -7.083,2.656 -10.956,3.652c-3.873,0.996 -8.798,1.494 -14.774,1.494c-5.976,0 -11.205,-0.941 -15.687,-2.822c-4.482,-1.881 -7.996,-5.118 -10.541,-9.711c-2.545,-4.593 -3.818,-11.039 -3.818,-19.339l0,-37.848l-11.122,0l0,-12.948l14.11,-9.96l8.134,-19.256l20.75,0l0,18.426l22.576,0l0,23.738l-22.576,0l0,35.69c0,5.976 2.877,8.964 8.632,8.964Zm119.852,-68.392l0,91.798l-23.904,0l-3.984,-11.454l-1.826,0c-2.877,4.648 -6.806,7.996 -11.786,10.043c-4.98,2.047 -10.292,3.071 -15.936,3.071c-5.976,0 -11.399,-1.162 -16.268,-3.486c-4.869,-2.324 -8.715,-5.976 -11.537,-10.956c-2.822,-4.98 -4.233,-11.399 -4.233,-19.256l0,-59.76l31.706,0l0,50.132c0,5.976 0.858,10.513 2.573,13.612c1.715,3.099 4.51,4.648 8.383,4.648c5.976,0 9.988,-2.435 12.035,-7.304c2.047,-4.869 3.071,-11.841 3.071,-20.916l0,-40.172l31.706,0Zm49.634,93.458c-9.407,0 -17.181,-4.039 -23.323,-12.118c-6.142,-8.079 -9.213,-19.865 -9.213,-35.358c0,-15.715 3.126,-27.584 9.379,-35.607c6.253,-8.023 14.359,-12.035 24.319,-12.035c6.197,0 11.15,1.273 14.857,3.818c3.707,2.545 6.778,5.755 9.213,9.628l0.664,0c-0.443,-2.324 -0.802,-5.533 -1.079,-9.628c-0.277,-4.095 -0.415,-8.079 -0.415,-11.952l0,-24.568l31.872,0l0,126.16l-23.904,0l-6.806,-11.62l-1.162,0c-2.213,3.652 -5.257,6.778 -9.13,9.379c-3.873,2.601 -8.964,3.901 -15.272,3.901Zm12.948,-25.066c5.091,0 8.687,-1.605 10.79,-4.814c2.103,-3.209 3.209,-8.134 3.32,-14.774l0,-2.49c0,-7.193 -1.024,-12.727 -3.071,-16.6c-2.047,-3.873 -5.838,-5.81 -11.371,-5.81c-3.763,0 -6.889,1.826 -9.379,5.478c-2.49,3.652 -3.735,9.351 -3.735,17.098c0,7.636 1.245,13.197 3.735,16.683c2.49,3.486 5.727,5.229 9.711,5.229Zm80.51,-105.41c4.537,0 8.494,0.941 11.869,2.822c3.375,1.881 5.063,5.644 5.063,11.288c0,5.423 -1.688,9.102 -5.063,11.039c-3.375,1.937 -7.332,2.905 -11.869,2.905c-4.648,0 -8.604,-0.968 -11.869,-2.905c-3.265,-1.937 -4.897,-5.616 -4.897,-11.039c0,-5.644 1.632,-9.407 4.897,-11.288c3.265,-1.881 7.221,-2.822 11.869,-2.822Zm15.77,37.018l0,91.798l-31.706,0l0,-91.798l31.706,0Zm108.896,45.65c0,15.383 -4.095,27.196 -12.284,35.441c-8.189,8.245 -19.422,12.367 -33.698,12.367c-8.853,0 -16.711,-1.854 -23.572,-5.561c-6.861,-3.707 -12.256,-9.13 -16.185,-16.268c-3.929,-7.138 -5.893,-15.798 -5.893,-25.979c0,-15.161 4.095,-26.837 12.284,-35.026c8.189,-8.189 19.477,-12.284 33.864,-12.284c8.853,0 16.683,1.826 23.489,5.478c6.806,3.652 12.173,8.992 16.102,16.019c3.929,7.027 5.893,15.632 5.893,25.813Zm-59.428,0c0,7.857 1.051,13.861 3.154,18.011c2.103,4.15 5.644,6.225 10.624,6.225c4.869,0 8.328,-2.075 10.375,-6.225c2.047,-4.15 3.071,-10.154 3.071,-18.011c0,-7.857 -1.024,-13.778 -3.071,-17.762c-2.047,-3.984 -5.561,-5.976 -10.541,-5.976c-4.759,0 -8.217,1.992 -10.375,5.976c-2.158,3.984 -3.237,9.905 -3.237,17.762Z" style="fill:#fff;"/><path d="M911.933,687.092l-61.752,0l0,-18.26l14.774,-5.644l0,-70.882l-14.774,-5.478l0,-18.26l61.752,0l0,18.26l-14.774,5.478l0,70.882l14.774,5.644l0,18.26Zm127.82,-93.458c10.513,0 18.537,2.656 24.07,7.968c5.533,5.312 8.3,13.889 8.3,25.73l0,59.76l-31.706,0l0,-49.966c0,-6.972 -0.941,-11.814 -2.822,-14.525c-1.881,-2.711 -4.648,-4.067 -8.3,-4.067c-5.091,0 -8.632,2.241 -10.624,6.723c-1.992,4.482 -2.988,10.818 -2.988,19.007l0,42.828l-31.706,0l0,-49.966c0,-6.64 -0.885,-11.399 -2.656,-14.276c-1.771,-2.877 -4.427,-4.316 -7.968,-4.316c-5.423,0 -9.102,2.435 -11.039,7.304c-1.937,4.869 -2.905,11.897 -2.905,21.082l0,40.172l-31.706,0l0,-91.798l23.904,0l4.648,11.288l0.83,0c2.435,-3.763 5.948,-6.861 10.541,-9.296c4.593,-2.435 10.264,-3.652 17.015,-3.652c6.751,0 12.312,1.134 16.683,3.403c4.371,2.269 7.94,5.395 10.707,9.379l0.996,0c2.877,-4.095 6.612,-7.249 11.205,-9.462c4.593,-2.213 9.766,-3.32 15.521,-3.32Zm97.94,0c11.62,0 20.695,2.877 27.224,8.632c6.529,5.755 9.794,13.944 9.794,24.568l0,60.258l-21.912,0l-6.142,-12.118l-0.664,0c-2.545,3.209 -5.174,5.838 -7.885,7.885c-2.711,2.047 -5.838,3.541 -9.379,4.482c-3.541,0.941 -7.857,1.411 -12.948,1.411c-7.968,0 -14.608,-2.435 -19.92,-7.304c-5.312,-4.869 -7.968,-12.339 -7.968,-22.41c0,-9.849 3.403,-17.153 10.209,-21.912c6.806,-4.759 16.683,-7.415 29.631,-7.968l15.272,-0.498l0,-1.328c0,-4.095 -0.996,-7 -2.988,-8.715c-1.992,-1.715 -4.703,-2.573 -8.134,-2.573c-3.652,0 -7.691,0.636 -12.118,1.909c-4.427,1.273 -8.909,2.905 -13.446,4.897l-9.13,-20.916c5.312,-2.767 11.316,-4.842 18.011,-6.225c6.695,-1.383 14.193,-2.075 22.493,-2.075Zm-1.826,52.788c-5.755,0.221 -9.822,1.245 -12.201,3.071c-2.379,1.826 -3.569,4.399 -3.569,7.719c0,3.099 0.83,5.395 2.49,6.889c1.66,1.494 3.873,2.241 6.64,2.241c3.873,0 7.166,-1.217 9.877,-3.652c2.711,-2.435 4.067,-5.589 4.067,-9.462l0,-7.138l-7.304,0.332Zm88.976,-52.788c6.419,0 11.592,1.245 15.521,3.735c3.929,2.49 7.11,5.561 9.545,9.213l0.664,0l2.324,-11.288l27.39,0l0,91.964c0,12.948 -4.012,22.797 -12.035,29.548c-8.023,6.751 -20.335,10.126 -36.935,10.126c-7.415,0 -13.944,-0.387 -19.588,-1.162c-5.644,-0.775 -11.067,-2.158 -16.268,-4.15l0,-26.394c5.533,2.324 10.79,4.067 15.77,5.229c4.98,1.162 11.067,1.743 18.26,1.743c12.727,0 19.09,-4.537 19.09,-13.612l0,-1.66c0,-3.209 0.332,-7.027 0.996,-11.454l-0.996,0c-2.103,3.652 -5.091,6.778 -8.964,9.379c-3.873,2.601 -8.964,3.901 -15.272,3.901c-9.739,0 -17.651,-4.039 -23.738,-12.118c-6.087,-8.079 -9.13,-19.865 -9.13,-35.358c0,-15.493 3.099,-27.307 9.296,-35.441c6.197,-8.134 14.221,-12.201 24.07,-12.201Zm11.952,24.568c-8.743,0 -13.114,7.857 -13.114,23.572c0,7.968 1.107,13.695 3.32,17.181c2.213,3.486 5.644,5.229 10.292,5.229c5.201,0 8.798,-1.632 10.79,-4.897c1.992,-3.265 2.988,-8.162 2.988,-14.691l0,-3.818c0,-7.193 -0.968,-12.754 -2.905,-16.683c-1.937,-3.929 -5.727,-5.893 -11.371,-5.893Zm105.908,-24.568c13.391,0 23.959,3.486 31.706,10.458c7.747,6.972 11.62,17.485 11.62,31.54l0,14.11l-56.606,0c0.221,4.759 2.02,8.687 5.395,11.786c3.375,3.099 8.272,4.648 14.691,4.648c5.755,0 11.011,-0.553 15.77,-1.66c4.759,-1.107 9.683,-2.877 14.774,-5.312l0,22.742c-4.427,2.324 -9.268,4.039 -14.525,5.146c-5.257,1.107 -11.869,1.66 -19.837,1.66c-9.296,0 -17.568,-1.632 -24.817,-4.897c-7.249,-3.265 -12.976,-8.383 -17.181,-15.355c-4.205,-6.972 -6.308,-15.881 -6.308,-26.726c0,-11.067 1.909,-20.141 5.727,-27.224c3.818,-7.083 9.13,-12.339 15.936,-15.77c6.806,-3.431 14.691,-5.146 23.655,-5.146Zm1.162,21.58c-3.763,0 -6.889,1.162 -9.379,3.486c-2.49,2.324 -3.956,6.031 -4.399,11.122l27.224,0c-0.111,-4.095 -1.3,-7.553 -3.569,-10.375c-2.269,-2.822 -5.561,-4.233 -9.877,-4.233Zm127.488,43.658c0,5.755 -1.3,10.901 -3.901,15.438c-2.601,4.537 -6.751,8.079 -12.45,10.624c-5.699,2.545 -13.197,3.818 -22.493,3.818c-6.529,0 -12.367,-0.36 -17.513,-1.079c-5.146,-0.719 -10.375,-2.075 -15.687,-4.067l0,-25.398c5.865,2.656 11.786,4.565 17.762,5.727c5.976,1.162 10.679,1.743 14.11,1.743c6.861,0 10.292,-1.549 10.292,-4.648c0,-1.328 -0.553,-2.49 -1.66,-3.486c-1.107,-0.996 -3.071,-2.103 -5.893,-3.32c-2.822,-1.217 -6.834,-2.877 -12.035,-4.98c-7.636,-3.209 -13.335,-6.889 -17.098,-11.039c-3.763,-4.15 -5.644,-9.988 -5.644,-17.513c0,-8.964 3.458,-15.715 10.375,-20.252c6.917,-4.537 16.074,-6.806 27.473,-6.806c6.087,0 11.786,0.664 17.098,1.992c5.312,1.328 10.845,3.265 16.6,5.81l-8.632,20.418c-4.537,-2.103 -9.13,-3.763 -13.778,-4.98c-4.648,-1.217 -8.355,-1.826 -11.122,-1.826c-5.201,0 -7.802,1.273 -7.802,3.818c0,1.107 0.47,2.103 1.411,2.988c0.941,0.885 2.739,1.881 5.395,2.988c2.656,1.107 6.529,2.711 11.62,4.814c5.312,2.103 9.711,4.399 13.197,6.889c3.486,2.49 6.087,5.478 7.802,8.964c1.715,3.486 2.573,7.94 2.573,13.363Z" style="fill:#fff;"/></g><defs><radialGradient id="_Radial1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,1022.208,-1679.52,0,473.39383,271.872181)"><stop offset="0" style="stop-color:#2f2f2f;stop-opacity:0.98"/><stop offset="1" style="stop-color:#111;stop-opacity:0.98"/></radialGradient></defs></svg>
\ No newline at end of file diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index 714c57d361..b188f5deb4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -95,7 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var posters = movie.Images.Posters; var backdrops = movie.Images.Backdrops; var logos = movie.Images.Logos; - var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0); + var remoteImages = new List<RemoteImageInfo>((posters?.Count ?? 0) + (backdrops?.Count ?? 0) + (logos?.Count ?? 0)); if (posters is not null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs b/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs index 4adde8366a..04f1fd04a3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Plugin.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; @@ -12,7 +13,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <summary> /// Plugin class for the TMDb library. /// </summary> - public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage { /// <summary> /// Initializes a new instance of the <see cref="Plugin"/> class. @@ -44,6 +45,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <inheritdoc/> public override string ConfigurationFileName => "Jellyfin.Plugin.Tmdb.xml"; + /// <inheritdoc/> + public string ImageResourceName => GetType().Namespace + ".jellyfin-plugin-tmdb.svg"; + /// <summary> /// Return the plugin configuration page. /// </summary> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index f0e159f098..21b822c97c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -181,7 +181,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV ParentIndexNumber = seasonNumber, IndexNumberEnd = info.IndexNumberEnd, Name = episodeResult.Name, - PremiereDate = episodeResult.AirDate, + PremiereDate = episodeResult.AirDate.HasValue + ? DateTime.SpecifyKind(episodeResult.AirDate.Value, DateTimeKind.Local).ToUniversalTime() + : null, ProductionYear = episodeResult.AirDate?.Year, Overview = episodeResult.Overview, CommunityRating = Convert.ToSingle(episodeResult.VoteAverage) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 1eb411f0f6..9bb15ca479 100644..100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -256,11 +256,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.Overview = seriesResult.Overview; + var studios = Enumerable.Empty<string>(); + if (seriesResult.Networks is not null) { - series.Studios = seriesResult.Networks.Select(i => i.Name).ToArray(); + studios = studios.Concat(seriesResult.Networks.Select(i => i.Name).OfType<string>()); + } + + if (seriesResult.ProductionCompanies is not null) + { + studios = studios.Concat(seriesResult.ProductionCompanies.Select(i => i.Name).OfType<string>()); } + series.SetStudios(studios); + if (seriesResult.Genres is not null) { series.Genres = seriesResult.Genres.Select(i => i.Name).ToArray(); @@ -320,13 +329,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)) @@ -417,6 +439,31 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV yield return personInfo; } } + + if (seriesResult.CreatedBy is not null) + { + foreach (var person in seriesResult.CreatedBy) + { + if (string.IsNullOrWhiteSpace(person.Name)) + { + continue; + } + + var personInfo = new PersonInfo + { + Name = person.Name.Trim(), + Type = PersonKind.Creator, + ImageUrl = _tmdbClientManager.GetProfileUrl(person.ProfilePath) + }; + + if (person.Id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture)); + } + + yield return personInfo; + } + } } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 39c0497bed..7e6b9beee9 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; @@ -48,6 +49,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb PersonKind.Producer }; + /// <summary> + /// Writing jobs to keep. + /// </summary> + private static readonly FrozenSet<string> _writerJobs = new[] + { + "writer", + "screenplay", + "novel" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + [GeneratedRegex(@"[\W_-[·]]+")] private static partial Regex NonWordRegex(); @@ -82,7 +93,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase) - && (string.Equals(crew.Job, "writer", StringComparison.OrdinalIgnoreCase) || string.Equals(crew.Job, "screenplay", StringComparison.OrdinalIgnoreCase))) + && crew.Job is not null && _writerJobs.Contains(crew.Job)) { return PersonKind.Writer; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/jellyfin-plugin-tmdb.svg b/MediaBrowser.Providers/Plugins/Tmdb/jellyfin-plugin-tmdb.svg new file mode 100644 index 0000000000..fbebb32b60 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/jellyfin-plugin-tmdb.svg @@ -0,0 +1,16 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080" viewBox="0 0 508 285.75"> + <defs> + <linearGradient id="a"> + <stop style="stop-color:#20464c;stop-opacity:1" offset="0"/> + <stop style="stop-color:#0d253f;stop-opacity:.999722" offset="1"/> + </linearGradient> + <linearGradient id="c" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse" gradientTransform="translate(95.25 74.882) scale(1.66894)"> + <stop offset="0" stop-color="#90cea1"/> + <stop offset=".56" stop-color="#3cbec9"/> + <stop offset="1" stop-color="#00b3e5"/> + </linearGradient> + <radialGradient xlink:href="#a" id="b" cx="125.255" cy="16.735" fx="125.255" fy="16.735" r="254" gradientTransform="matrix(0 1.06475 -1.74952 0 154.532 -61.444)" gradientUnits="userSpaceOnUse"/> + </defs> + <path style="opacity:.98;fill:url(#b);stroke-width:2.64583;fill-opacity:1" d="M0 0h508v285.75H0z"/> + <path d="M271.607 135.064H383.26a29.49 29.49 0 0 0 29.49-29.473 29.49 29.49 0 0 0-29.49-29.49H271.607a29.49 29.49 0 0 0-29.49 29.49 29.49 29.49 0 0 0 29.49 29.473zM124.74 210.167h128.342a29.49 29.49 0 0 0 29.49-29.474 29.49 29.49 0 0 0-29.49-29.49H124.74a29.49 29.49 0 0 0-29.49 29.49 29.49 29.49 0 0 0 29.49 29.474zm-12.116-76.17h13.017V86.43h16.857V74.882h-46.73v11.516h16.856zm46.897 0h13.018V88.65h.167l15.02 45.312h10.014l15.52-45.312h.167v45.312h13.018v-59.08h-19.777l-13.686 38.552h-.167l-13.601-38.553H159.52zm190.126 33.795a25.151 25.151 0 0 0-7.543-9.212 30.992 30.992 0 0 0-11.149-5.14 55.976 55.976 0 0 0-13.468-1.67H297.96v59.081h21.279a41.023 41.023 0 0 0 12.6-1.92 32.277 32.277 0 0 0 10.598-5.54 27.154 27.154 0 0 0 7.294-9.18 28.222 28.222 0 0 0 2.72-12.65 30.875 30.875 0 0 0-2.804-13.769zm-12.4 21.58a14.687 14.687 0 0 1-4.406 5.674 17.858 17.858 0 0 1-6.676 3.038 36 36 0 0 1-8.345.918h-6.759v-35.048h7.677a28.372 28.372 0 0 1 7.794 1.051 19.46 19.46 0 0 1 6.476 3.121 15.254 15.254 0 0 1 4.239 5.224 16.472 16.472 0 0 1 1.669 7.544 19.844 19.844 0 0 1-1.67 8.478zm74.485-.217a13.352 13.352 0 0 0-2.637-4.373 13.986 13.986 0 0 0-4.039-3.087 17.207 17.207 0 0 0-5.29-1.67v-.166a15.388 15.388 0 0 0 7.376-4.707 12.4 12.4 0 0 0 2.804-8.344 14.053 14.053 0 0 0-1.92-7.761 13.502 13.502 0 0 0-5.006-4.54 20.962 20.962 0 0 0-6.976-2.17 54.808 54.808 0 0 0-7.71-.55h-22.03v59.08h24.199a37.401 37.401 0 0 0 7.877-.834 22.58 22.58 0 0 0 7.143-2.754 15.721 15.721 0 0 0 5.174-5.006 14.22 14.22 0 0 0 2.003-7.811 15.671 15.671 0 0 0-.918-5.307zm-32.411-26.286h8.845a16.69 16.69 0 0 1 3.088.3 10.314 10.314 0 0 1 2.837.952 5.658 5.658 0 0 1 2.036 1.885 5.374 5.374 0 0 1 .801 3.038 6.058 6.058 0 0 1-.717 3.004 5.674 5.674 0 0 1-1.87 2.003 8.211 8.211 0 0 1-2.636 1.085 12.534 12.534 0 0 1-2.954.333h-9.43zm19.56 33.379a6.509 6.509 0 0 1-2.036 2.17 7.744 7.744 0 0 1-2.804 1.168 13.652 13.652 0 0 1-3.037.333H379.32v-13.351h9.847a25.618 25.618 0 0 1 3.338.25 14.136 14.136 0 0 1 3.421.918 6.676 6.676 0 0 1 2.62 1.97 5.19 5.19 0 0 1 1.052 3.338 6.192 6.192 0 0 1-.718 3.204z" style="fill:url(#c);stroke-width:1.66894"/> +</svg> diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 61a31fbfd6..b350f482c3 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -78,11 +78,73 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> { await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false); + // Note that this only updates the children's SeriesPresentationUniqueKey and SeasonId, not the ParentIndexNumber + if (LibraryManager.GetLibraryOptions(item).EnableAutomaticSeriesGrouping) + { + await UpdateSeriesChildrenInfoAsync(item, cancellationToken).ConfigureAwait(false); + } + RemoveObsoleteEpisodes(item); RemoveObsoleteSeasons(item); await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false); } + /// <summary> + /// Reconciles seasons and episodes with the series' finalized state. + /// </summary> + /// <remarks> + /// The series' presentation unique key can change during a refresh once provider ids become + /// available - notably with <c>EnableAutomaticSeriesGrouping</c>, where the key is derived from + /// the provider id and the owning libraries instead of the (immutable) item id. Seasons and + /// episodes cache this value in <see cref="IHasSeries.SeriesPresentationUniqueKey"/> and are + /// matched to (and displayed under) the series by it, so any child left with a stale key - or an + /// episode not yet linked to a freshly created season - stays hidden until a later scan. Syncing + /// them against the series here lets everything appear within a single scan. + /// </remarks> + /// <param name="series">The series.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The async task.</returns> + private async Task UpdateSeriesChildrenInfoAsync(Series series, CancellationToken cancellationToken) + { + // Reload children so episode numbers / seasons persisted earlier in the refresh are seen. + series.Children = null; + var seriesKey = series.GetPresentationUniqueKey(); + var children = series.GetRecursiveChildren(i => i is Season || i is Episode); + var seasons = children.OfType<Season>().ToList(); + + foreach (var child in children) + { + var updateType = ItemUpdateType.None; + + if (child is IHasSeries hasSeries + && !string.Equals(hasSeries.SeriesPresentationUniqueKey, seriesKey, StringComparison.Ordinal)) + { + hasSeries.SeriesPresentationUniqueKey = seriesKey; + updateType |= ItemUpdateType.MetadataImport; + } + + if (child is Episode episode) + { + var seasonId = episode.FindSeasonId(); + if (seasonId.IsEmpty() && episode.ParentIndexNumber.HasValue) + { + seasonId = seasons.Find(s => s.IndexNumber == episode.ParentIndexNumber)?.Id ?? Guid.Empty; + } + + if (!seasonId.IsEmpty() && !episode.SeasonId.Equals(seasonId)) + { + episode.SeasonId = seasonId; + updateType |= ItemUpdateType.MetadataImport; + } + } + + if (updateType > ItemUpdateType.None) + { + await child.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false); + } + } + } + /// <inheritdoc /> protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { @@ -210,16 +272,19 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> return true; } - // Not yet processed - if (episode.SeasonId.IsEmpty()) + // Episode has been processed and linked to a season, only needs a virtual season + // if it isn't already linked to a known physical season by ID or path + if (!episode.SeasonId.IsEmpty()) { - return false; + return !physicalSeasonIds.Contains(episode.SeasonId) + && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty); } - // Episode has been processed, only needs a virtual season if it isn't - // already linked to a known physical season by ID or path - return !physicalSeasonIds.Contains(episode.SeasonId) - && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty); + // Episode not yet linked, check if it's in a physical season folder + // If yes then skip it, processing not finished + // If no then include it, needs Season Unknown + var episodeDirectory = System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty; + return !physicalSeasonPaths.Contains(episodeDirectory); } /// <summary> @@ -232,7 +297,30 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken) { var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); + + // CreateSeasonsAsync can run before the episodes themselves have been refreshed during an + // initial scan, so their ParentIndexNumber may still be unset. Resolve the season number + // from the path first to avoid creating a premature "Season Unknown" instead of the real + // season for episodes that live directly in a flat series folder. + foreach (var episode in seriesChildren.OfType<Episode>()) + { + if (episode.ParentIndexNumber.HasValue) + { + continue; + } + + try + { + LibraryManager.FillMissingEpisodeNumbersFromPath(episode, false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error resolving season number from path for {Path}", episode.Path); + } + } + var seasons = seriesChildren.OfType<Season>().ToList(); + var episodes = seriesChildren.OfType<Episode>().ToList(); var physicalSeasonIds = seasons .Where(e => e.LocationType != LocationType.Virtual) @@ -258,11 +346,12 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> if (existingSeason is null) { var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber); - await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false); + var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false); + seasons.Add(season); } else if (existingSeason.IsVirtualItem) { - var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber && !e.IsMissingEpisode); + var episodeCount = episodes.Count(e => e.ParentIndexNumber == seasonNumber && !e.IsMissingEpisode); if (episodeCount > 0) { existingSeason.IsVirtualItem = false; @@ -270,6 +359,21 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> } } } + + // Loop through episodes + foreach (var episode in episodes) + { + var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber); + if (season is null || episode.SeasonId.Equals(season.Id)) + { + continue; + } + + // Assign the correct season id and name to episode. + episode.SeasonId = season.Id; + episode.SeasonName = season.Name; + await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } } /// <summary> @@ -280,7 +384,7 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> /// <param name="seasonNumber">The season number.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The newly created season.</returns> - private async Task CreateSeasonAsync( + private async Task<Season> CreateSeasonAsync( Series series, string? seasonName, int? seasonNumber, @@ -303,6 +407,8 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> series.AddChild(season); await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false); + + return season; } private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber) diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalUrlProvider.cs b/MediaBrowser.Providers/TV/Zap2ItExternalUrlProvider.cs index 52b0583e58..e01b6c78ed 100644 --- a/MediaBrowser.Providers/TV/Zap2ItExternalUrlProvider.cs +++ b/MediaBrowser.Providers/TV/Zap2ItExternalUrlProvider.cs @@ -19,6 +19,6 @@ public class Zap2ItExternalUrlProvider : IExternalUrlProvider if (item.TryGetProviderId(MetadataProvider.Zap2It, out var externalId)) { yield return $"http://tvlistings.zap2it.com/overview.html?programSeriesId={externalId}"; - } + } } } diff --git a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs index 137abff478..f013863336 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs @@ -96,7 +96,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers var artist = reader.ReadNormalizedString(); if (!string.IsNullOrEmpty(artist) && item is MusicVideo artistVideo) { - artistVideo.Artists = [..artistVideo.Artists, artist]; + artistVideo.Artists = [.. artistVideo.Artists, artist]; } break; diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index ed32e6c76a..78907a5e68 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -198,15 +198,23 @@ namespace MediaBrowser.XbmcMetadata.Savers cancellationToken.ThrowIfCancellationRequested(); - await SaveToFileAsync(memoryStream, path).ConfigureAwait(false); + await SaveToFileAsync(memoryStream, path, cancellationToken).ConfigureAwait(false); } } - private async Task SaveToFileAsync(Stream stream, string path) + private async Task SaveToFileAsync(Stream stream, string path, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); Directory.CreateDirectory(directory); + // Compare byte-for-byte before proceeding. + if (File.Exists(path) && await stream.IsFileIdenticalAsync(path, cancellationToken).ConfigureAwait(false)) + { + return; // Don't save since .nfo is unchanged. + } + + stream.Position = 0; + // On Windows, saving the file will fail if the file is hidden or readonly FileSystem.SetAttributes(path, false, false); @@ -222,7 +230,7 @@ namespace MediaBrowser.XbmcMetadata.Savers var filestream = new FileStream(path, fileStreamOptions); await using (filestream.ConfigureAwait(false)) { - await stream.CopyToAsync(filestream).ConfigureAwait(false); + await stream.CopyToAsync(filestream, cancellationToken).ConfigureAwait(false); } if (ConfigurationManager.Configuration.SaveMetadataHidden) @@ -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/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index 6c81fa729c..b10e210e5d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs @@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId); Username = username; + NormalizedUsername = username.ToUpperInvariant(); AuthenticationProviderId = authenticationProviderId; PasswordResetProviderId = passwordResetProviderId; @@ -74,6 +75,16 @@ namespace Jellyfin.Database.Implementations.Entities public string Username { get; set; } /// <summary> + /// Gets or sets the user's normalized name. + /// </summary> + /// <remarks> + /// Required, Max length = 255. + /// </remarks> + [MaxLength(255)] + [StringLength(255)] + public string NormalizedUsername { get; set; } + + /// <summary> /// Gets or sets the user's password, or <c>null</c> if none is set. /// </summary> /// <remarks> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs index b2bcbf2bb6..34810b9199 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs @@ -108,5 +108,50 @@ public enum ViewType /// <summary> /// Shows upcoming. /// </summary> - Upcoming = 20 + Upcoming = 20, + + /// <summary> + /// Shows authors. + /// </summary> + Authors = 21, + + /// <summary> + /// Shows books. + /// </summary> + Books = 22, + + /// <summary> + /// Shows folders. + /// </summary> + Folders = 23, + + /// <summary> + /// Shows mixed media. + /// </summary> + Mixed = 24, + + /// <summary> + /// Shows photos. + /// </summary> + Photos = 25, + + /// <summary> + /// Shows photo albums. + /// </summary> + PhotoAlbums = 26, + + /// <summary> + /// Shows series timers. + /// </summary> + SeriesTimers = 27, + + /// <summary> + /// Shows studios. + /// </summary> + Studios = 28, + + /// <summary> + /// Shows videos. + /// </summary> + Videos = 29 } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index f386e882e2..1af7460540 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -112,6 +112,92 @@ public static class JellyfinQueryHelperExtensions } /// <summary> + /// Filters items that match any of the specified (provider name, value) pairs. + /// </summary> + /// <param name="baseQuery">The source query.</param> + /// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</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))); + } + + /// <summary> + /// Filters items that have any of the specified providers. Empty/null values match any value for that provider. + /// </summary> + /// <param name="baseQuery">The source query.</param> + /// <param name="providerIds">Dictionary mapping provider names to optional values.</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))); + } + + /// <summary> + /// Excludes items that match any of the specified (provider name, value) pairs. + /// </summary> + /// <param name="baseQuery">The source query.</param> + /// <param name="providerIds">Dictionary mapping provider names to values to exclude.</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(); + + if (excludeKeys.Count == 0) + { + return baseQuery; + } + + return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + + /// <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> @@ -138,9 +224,10 @@ public static class JellyfinQueryHelperExtensions var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); - if (oneOf.Count < 4) // arbitrary value choosen. + // 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 we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs index 61b5e06e8a..ed4138680d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs @@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration builder .HasIndex(entity => entity.Username) .IsUnique(); + + builder + .HasIndex(entity => entity.NormalizedUsername) + .IsUnique(); } } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs index 55b90a54d7..ff10440e0c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.cs index 8cc7fb452d..9c03bfed9d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241020103111_LibraryDbMigration.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.cs index ac78019eda..3fe61f91df 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111131257_AddedCustomDataKey.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.cs index 4558d7c49c..d6b351e2ab 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241111135439_AddedCustomDataKeyKey.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.cs index 70e81f3676..a7c9e6fb50 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112152323_FixAncestorIdConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.cs index d57ea81b3a..ab8b792a5f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112232041_fixMediaStreams.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.cs index 78611b9e4c..1ed23e7c42 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241112234144_FixMediaStreams2.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.cs index d1b06ceaec..e3a3f3ac64 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20241113133548_EnforceUniqueItemValue.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.cs index e82575e418..3d4fd85af2 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250202021306_FixedCollation.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.cs index 2c60dd7a62..1493df35d0 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250204092455_MakeStartEndDateNullable.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.cs index 1e904e833e..713b5c0434 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250214031148_ChannelIdGuid.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.cs index 71f56a1492..7049ccc214 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250326065026_AddInheritedParentalRatingSubValue.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs index c17b35b404..d84940b7e6 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.cs index 5766cd3825..63010679e5 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327171413_AddHdr10PlusFlag.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs index f921856a20..ceb3d32452 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331182844_FixAttachmentMigration.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.cs index e1220bfcf7..1f6012bbf2 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250401142247_FixAncestors.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.cs index aa667bafd4..6032969cf3 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250405075612_FixItemValuesIndices.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs index 2935a608d1..a3d8fe2c3a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs index bce6029d5b..44b44dd581 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs index 23cb0c8ba5..e88518d74a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 38033d07f0..a7f5e369ab 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 @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs index 7c1bcdf445..097504aebb 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113102337_AddLinkedChildrenTable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113102337_AddLinkedChildrenTable.cs index 198bc78cff..1ab6b4240a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113102337_AddLinkedChildrenTable.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113102337_AddLinkedChildrenTable.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 6334d8b5f1..4927b0e78d 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 @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 0e28abc862..f9cb9aa736 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 @@ -270,6 +270,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.cs index c84086d992..39a0805d2a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233000_AddForeignKeyToOwnerId.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -23,12 +23,40 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations name: "BaseItemEntityId", table: "BaseItems"); + migrationBuilder.Sql( + """ + UPDATE BaseItems + SET OwnerId = '00000000-0000-0000-0000-000000000001' + WHERE OwnerId IS NOT NULL + AND OwnerId NOT IN (SELECT Id FROM BaseItems); + """); + migrationBuilder.AddForeignKey( name: "FK_BaseItems_BaseItems_OwnerId", table: "BaseItems", column: "OwnerId", principalTable: "BaseItems", principalColumn: "Id"); + + migrationBuilder.AddColumn<bool>( + name: "IsOriginal", + table: "MediaStreamInfos", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn<string>( + name: "OriginalLanguage", + table: "BaseItems", + type: "TEXT", + nullable: true); + + migrationBuilder.UpdateData( + table: "BaseItems", + keyColumn: "Id", + keyValue: new Guid("00000000-0000-0000-0000-000000000001"), + column: "OriginalLanguage", + value: null); } /// <inheritdoc /> @@ -62,6 +90,14 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations column: "BaseItemEntityId", principalTable: "BaseItems", principalColumn: "Id"); + + migrationBuilder.DropColumn( + name: "IsOriginal", + table: "MediaStreamInfos"); + + migrationBuilder.DropColumn( + name: "OriginalLanguage", + table: "BaseItems"); } } } 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 92ed0cf6bf..29874264af 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.cs index 5387d3351d..6440d0a395 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113233500_DropExtraIdsColumn.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 89fb3ee815..8282a8a582 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.cs index ba1a131e9b..710ffc35b1 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260116114245_AddLatestItemsDateCreatedIndexes.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 83a6a7baf3..5541a0191b 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.cs index 8c8768645b..7e1d619b8a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260118182305_AddIndicesToImageInfo.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 1b396a707c..f6fd1db21e 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs index da57c71662..0b540d799b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260130232147_AddBaseItemNameIndex.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 ca995decde..5f7131ff65 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.cs index 92836e753f..ef0c039ffe 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260206224832_IndexOptimizations.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 0184154566..0499921fec 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .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 4c9ccc13bf..bf46ad9b39 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.cs index 3932e1c3e4..00d4f24403 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260308123920_AddTypeCleanNameIndex.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable 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 23ab2a4674..fc5c7afa0e 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 @@ -267,6 +267,9 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.cs index e1f62c12fb..ad51786581 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504075755_AddPartialIndexForItemCounts.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.cs deleted file mode 100644 index cda226309a..0000000000 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Jellyfin.Database.Providers.Sqlite.Migrations -{ - /// <inheritdoc /> - public partial class AddOriginalLanguage : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn<bool>( - name: "IsOriginal", - table: "MediaStreamInfos", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn<string>( - name: "OriginalLanguage", - table: "BaseItems", - type: "TEXT", - nullable: true); - - migrationBuilder.UpdateData( - table: "BaseItems", - keyColumn: "Id", - keyValue: new Guid("00000000-0000-0000-0000-000000000001"), - column: "OriginalLanguage", - value: null); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "IsOriginal", - table: "MediaStreamInfos"); - - migrationBuilder.DropColumn( - name: "OriginalLanguage", - table: "BaseItems"); - } - } -} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs index e0f5125da1..63f858bc98 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.Designer.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs @@ -1,4 +1,4 @@ -// <auto-generated /> +// <auto-generated /> using System; using Jellyfin.Database.Implementations; using Microsoft.EntityFrameworkCore; @@ -8,11 +8,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Jellyfin.Database.Providers.Sqlite.Migrations +namespace Jellyfin.Server.Implementations.Migrations { [DbContext(typeof(JellyfinDbContext))] - [Migration("20260504180809_AddOriginalLanguage")] - partial class AddOriginalLanguage + [Migration("20260522092303_AddNormalizedUsername")] + partial class AddNormalizedUsername { /// <inheritdoc /> protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -961,9 +961,6 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); - b.Property<bool>("IsOriginal") - .HasColumnType("INTEGER"); - b.Property<string>("KeyFrames") .HasColumnType("TEXT"); @@ -1351,6 +1348,11 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations b.Property<bool>("MustUpdatePassword") .HasColumnType("INTEGER"); + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + b.Property<string>("Password") .HasMaxLength(65535) .HasColumnType("TEXT"); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.cs new file mode 100644 index 0000000000..670f59ba7a --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class AddNormalizedUsername : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<string>( + name: "NormalizedUsername", + table: "Users", + type: "TEXT", + maxLength: 255, + nullable: false, + defaultValue: string.Empty); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("ALTER TABLE Users DROP COLUMN NormalizedUsername;"); + + migrationBuilder.Sql( + @"DELETE FROM __EFMigrationsHistory + WHERE MigrationId = '20260522092304_UpdateNormalizedUsername'"); + } + } +} 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 new file mode 100644 index 0000000000..a1f555a59b --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.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.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260524120336_AddUniqueNormalizedUsernameIndex")] + partial class AddUniqueNormalizedUsernameIndex + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + 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<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("ChildType") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "ChildId"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.HasIndex("ParentId", "SortOrder"); + + 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<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/20260524120336_AddUniqueNormalizedUsernameIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs new file mode 100644 index 0000000000..6c17775d16 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class AddUniqueNormalizedUsernameIndex : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Users_NormalizedUsername", + table: "Users", + column: "NormalizedUsername", + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_NormalizedUsername", + table: "Users"); + } + } +} 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 86b838d64e..fd18c035e6 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.7"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => { @@ -1348,6 +1348,11 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<bool>("MustUpdatePassword") .HasColumnType("INTEGER"); + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + b.Property<string>("Password") .HasMaxLength(65535) .HasColumnType("TEXT"); @@ -1390,6 +1395,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); + b.HasIndex("NormalizedUsername") + .IsUnique(); + b.HasIndex("Username") .IsUnique(); diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 3f7ae4d2cd..b6d2914efa 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -234,20 +234,20 @@ public class SkiaEncoder : IImageEncoder return default; default: - { - var boundsInfo = SKBitmap.DecodeBounds(safePath); - if (boundsInfo.Width > 0 && boundsInfo.Height > 0) { - return new ImageDimensions(boundsInfo.Width, boundsInfo.Height); + var boundsInfo = SKBitmap.DecodeBounds(safePath); + if (boundsInfo.Width > 0 && boundsInfo.Height > 0) + { + return new ImageDimensions(boundsInfo.Width, boundsInfo.Height); + } + + _logger.LogWarning( + "Unable to determine image dimensions for {FilePath}: {SkCodecResult}", + path, + result); + + return default; } - - _logger.LogWarning( - "Unable to determine image dimensions for {FilePath}: {SkCodecResult}", - path, - result); - - return default; - } } } finally @@ -398,7 +398,7 @@ public class SkiaEncoder : IImageEncoder try { - // If we have to resize these they often end up distorted + // If we have to resize these they often end up distorted if (resultBitmap.ColorType == SKColorType.Gray8) { using (resultBitmap) diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index 0cfac384e3..36361c58e8 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -1,17 +1,22 @@ +using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Threading; +using System.Threading.Tasks; namespace Jellyfin.Extensions { /// <summary> - /// Class BaseExtensions. + /// Extension methods for the <see cref="Stream"/> class. /// </summary> public static class StreamExtensions { + private const int StreamComparisonBufferSize = 81920; + /// <summary> /// Reads all lines in the <see cref="Stream" />. /// </summary> @@ -60,5 +65,172 @@ namespace Jellyfin.Extensions yield return line; } } + + /// <summary> + /// Determines whether a stream is identical to a file on disk. + /// </summary> + /// <param name="stream">The stream to compare.</param> + /// <param name="path">The file path to compare against.</param> + /// <param name="cancellationToken">The token to monitor for cancellation requests.</param> + /// <returns>True if the stream and file are identical; otherwise false.</returns> + /// <exception cref="ArgumentException"><paramref name="stream"/> does not support seeking.</exception> + /// <remarks> + /// The entire stream is compared against the file from the beginning (the position is reset to 0 on entry) + /// and restored to its original value after the call. + /// </remarks> + public static async Task<bool> IsFileIdenticalAsync(this Stream stream, string path, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrEmpty(path); + + if (!stream.CanSeek) + { + throw new ArgumentException("Stream must support seeking.", nameof(stream)); + } + + var originalPosition = stream.Position; + try + { + stream.Position = 0; + + var existingFileStream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: StreamComparisonBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using (existingFileStream.ConfigureAwait(false)) + { + return await stream.IsStreamIdenticalAsync(existingFileStream, cancellationToken).ConfigureAwait(false); + } + } + finally + { + stream.Position = originalPosition; + } + } + + /// <summary> + /// Determines whether two streams are identical. + /// </summary> + /// <param name="a">The first stream to compare.</param> + /// <param name="b">The second stream to compare.</param> + /// <param name="cancellationToken">The token to monitor for cancellation requests.</param> + /// <returns>True if the streams are identical; otherwise false.</returns> + /// <remarks> + /// Seekable streams are compared from the beginning (their position is reset to 0 on entry). + /// Non-seekable streams are compared from their current read position. Stream positions are not + /// restored after the call. + /// </remarks> + public static async Task<bool> IsStreamIdenticalAsync(this Stream a, Stream b, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a.CanSeek is var aCanSeek && aCanSeek) + { + a.Position = 0; + } + + if (b.CanSeek is var bCanSeek && bCanSeek) + { + b.Position = 0; + } + + if (aCanSeek && bCanSeek && b.Length != a.Length) + { + return false; + } + + // MemoryStreams only unlock a fast path if their underlying buffer is exposed via TryGetBuffer. + var segmentA = a is MemoryStream streamA && streamA.TryGetBuffer(out var bufA) ? bufA : default; + var segmentB = b is MemoryStream streamB && streamB.TryGetBuffer(out var bufB) ? bufB : default; + + // Fast path A: both streams expose buffers, compare segments directly + if (segmentA.Array is not null && segmentB.Array is not null) + { + return segmentA.AsSpan().SequenceEqual(segmentB.AsSpan()); + } + + if (segmentB.Array is not null) // && segmentA.Array is null guaranteed by previous check + { + // swap so that segmentA is the non-null one, compared to b we need only one fast path B + (segmentA, b) = (segmentB, a); + } + + if (segmentA.Array is not null) // either a was non-null, or b was non-null and was swapped there + { + // Fast path B: only one stream exposed a buffer, compare against the other chunk-by-chunk + var bufferB = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize); + try + { + var memoryB = bufferB.AsMemory(); + int offset = 0; + int bytesRead; + while ((bytesRead = await b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false)) > 0) + { + if (offset + bytesRead > segmentA.Count || !segmentA.AsSpan(offset, bytesRead).SequenceEqual(memoryB.Span[..bytesRead])) + { + return false; + } + + offset += bytesRead; + } + + return offset == segmentA.Count; + } + finally + { + ArrayPool<byte>.Shared.Return(bufferB); + } + } + else + { + var bufferA = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize); + var bufferB = ArrayPool<byte>.Shared.Rent(StreamComparisonBufferSize); + try + { + var memoryA = bufferA.AsMemory(); + var memoryB = bufferB.AsMemory(); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var taskA = a.ReadAtLeastAsync(memoryA, memoryA.Length, throwOnEndOfStream: false, cancellationToken).AsTask(); + var taskB = b.ReadAtLeastAsync(memoryB, memoryB.Length, throwOnEndOfStream: false, cancellationToken).AsTask(); + await Task.WhenAll(taskA, taskB).ConfigureAwait(false); + + var bytesReadA = await taskA.ConfigureAwait(false); + var bytesReadB = await taskB.ConfigureAwait(false); + + if (bytesReadA != bytesReadB) + { + return false; + } + + if (bytesReadA == 0) + { + return true; + } + + if (!memoryA.Span[..bytesReadA].SequenceEqual(memoryB.Span[..bytesReadB])) + { + return false; + } + } + } + finally + { + ArrayPool<byte>.Shared.Return(bufferA); + ArrayPool<byte>.Shared.Return(bufferB); + } + } + } } } diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index 2b8e5a0a08..e421601092 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -14,6 +14,7 @@ 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; @@ -1109,9 +1110,8 @@ namespace Jellyfin.LiveTv.Channels item.Path = mediaSource?.Path; } - if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl)) { - item.SetImagePath(ImageType.Primary, info.ImageUrl); _logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name); forceUpdate = true; } @@ -1129,7 +1129,7 @@ namespace Jellyfin.LiveTv.Channels { if (!item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase)) { - item.Tags = [..item.Tags, "livestream"]; + item.Tags = [.. item.Tags, "livestream"]; _logger.LogDebug("Forcing update due to Tags {0}", item.Name); forceUpdate = true; } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..4e1b62cdf9 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -5,7 +5,9 @@ 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; @@ -448,18 +450,9 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (!item.HasImage(ImageType.Primary)) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl)) { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } + forceUpdate = true; } if (isNew) @@ -494,8 +487,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)) @@ -621,13 +619,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)) @@ -640,6 +634,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 d877a0d124..19c4514766 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO CreateNoWindow = true, UseShellExecute = false, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true, RedirectStandardInput = true, diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs index 58683deb30..15e20d6f64 100644 --- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs +++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs @@ -67,7 +67,7 @@ public class ListingsManager : IListingsManager if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) { info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - config.ListingProviders = [..list, info]; + config.ListingProviders = [.. list, info]; } else { @@ -255,7 +255,7 @@ public class ListingsManager : IListingsManager Name = tunerChannelNumber, Value = providerChannelNumber }; - listingsProviderInfo.ChannelMappings = [..listingsProviderInfo.ChannelMappings, newItem]; + listingsProviderInfo.ChannelMappings = [.. listingsProviderInfo.ChannelMappings, newItem]; } _config.SaveConfiguration("livetv", config); diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 3aa0f0408b..c93d1f039c 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; } @@ -684,27 +702,37 @@ namespace Jellyfin.LiveTv.Listings sdCode?.ToString() ?? "N/A", responseBody); - if (sdCode is SdErrorCode.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode.AccountExpired or SdErrorCode.PasswordRequired) + if (sdCode is SdErrorCode.AccountExpired or SdErrorCode.InvalidHash or SdErrorCode.InvalidUser or SdErrorCode.AccountLocked or SdErrorCode.AppLocked or SdErrorCode.AccountInactive) { // Permanent account errors — disable SD for this server lifetime. - _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart", sdCode); + _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart.", sdCode); _tokens.Clear(); _accountError = true; } - else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout) + else if (sdCode is SdErrorCode.ServiceOffline or SdErrorCode.ServiceBusy or SdErrorCode.AccountTempLock) { // Transient login errors — back off for 30 minutes, then allow retry. + _logger.LogError("Schedules Direct transient error (code {SdCode}). Backing off for 30 minutes.", sdCode); _tokens.Clear(); Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks); } - else if (sdCode is SdErrorCode.MaxImageDownloads) + else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.MaxIPAttempts) + { + // 24 hour bans - stop image and metadata requests until SD reset at 00:00 UTC. + _logger.LogError("Schedules Direct service limit error (code {SdCode}). Disabling until SD reset.", sdCode); + SetImageLimitHit(); + SetMetadataLimitHit(); + } + else if (sdCode is SdErrorCode.MaxImageDownloads or SdErrorCode.MaxImageDownloadsTrial) { // Max image downloads — stop image requests until SD resets at 00:00 UTC. + _logger.LogError("Schedules Direct image download limit hit (code {SdCode}). Disabling image acquisition until SD reset.", sdCode); SetImageLimitHit(); } else if (sdCode is SdErrorCode.MaxScheduleRequests) { // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC. + _logger.LogError("Schedules Direct metadata download limit hit (code {SdCode}). Disabling metadata acquisition until SD reset.", sdCode); SetMetadataLimitHit(); } else if (enableRetry @@ -738,9 +766,7 @@ namespace Jellyfin.LiveTv.Listings #pragma warning disable CA5350 // SchedulesDirect is always SHA1. var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); #pragma warning restore CA5350 - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); + string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes); options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs index ec6c6c475b..fffbfb9a58 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/SdErrorCode.cs @@ -3,39 +3,59 @@ namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos; /// <summary> -/// Schedules Direct API error codes. +/// Schedules Direct API error codes. See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#error-response for details. /// </summary> public enum SdErrorCode { /// <summary> - /// Invalid user. + /// Schedules Direct unavailable/out of service. /// </summary> - InvalidUser = 4001, + ServiceOffline = 3000, + + /// <summary> + /// Schedules Direct busy. + /// </summary> + ServiceBusy = 3001, + + /// <summary> + /// Account expired. + /// </summary> + AccountExpired = 4001, /// <summary> /// Invalid password hash. /// </summary> - InvalidHash = 4003, + InvalidHash = 4002, /// <summary> - /// Account locked or disabled. + /// Invalid user or password. /// </summary> - AccountLocked = 4004, + InvalidUser = 4003, /// <summary> - /// Account expired. + /// Account temporarily locked due to login failures. + /// </summary> + AccountTempLock = 4004, + + /// <summary> + /// Account permanently locked due to abuse. /// </summary> - AccountExpired = 4005, + AccountLocked = 4005, /// <summary> - /// Token has expired. + /// Token has expired. Request a new one. /// </summary> TokenExpired = 4006, /// <summary> - /// Password is required. + /// Application locked out. /// </summary> - PasswordRequired = 4008, + AppLocked = 4007, + + /// <summary> + /// Account not active. + /// </summary> + AccountInactive = 4008, /// <summary> /// Maximum login attempts exceeded. @@ -43,9 +63,19 @@ public enum SdErrorCode MaxLoginAttempts = 4009, /// <summary> - /// Temporary lockout. + /// Maximum unique IP attempts reached. + /// </summary> + MaxIPAttempts = 4010, + + /// <summary> + /// Lineup change maximum reached. /// </summary> - TemporaryLockout = 4010, + MaxScheduleRequests = 4100, + + /// <summary> + /// Requested image not found. + /// </summary> + ImageNotFound = 5000, /// <summary> /// Maximum image downloads reached for the day. @@ -53,7 +83,12 @@ public enum SdErrorCode MaxImageDownloads = 5002, /// <summary> + /// Trial specific maximum image downloads reached for the day. + /// </summary> + MaxImageDownloadsTrial = 5003, + + /// <summary> /// Maximum schedule/metadata requests reached for the day. /// </summary> - MaxScheduleRequests = 5003 + MaxInvalidImages = 5004 } diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index ec2e6cfcc9..0aeb7ad05d 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Jellyfin.Extensions; using Jellyfin.XmlTv; using Jellyfin.XmlTv.Entities; +using Jellyfin.XmlTv.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; @@ -172,7 +173,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) @@ -180,6 +203,8 @@ namespace Jellyfin.LiveTv.Listings string? episodeTitle = program.Episode?.Title; var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); var imageUrl = program.Icons.FirstOrDefault()?.Source; + var episodeImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Still)?.Path; + var backgroundImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Backdrop)?.Path; var rating = program.Ratings.FirstOrDefault()?.Value; var starRating = program.StarRatings?.FirstOrDefault()?.StarRating; @@ -205,6 +230,8 @@ namespace Jellyfin.LiveTv.Listings IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl, HasImage = !string.IsNullOrEmpty(imageUrl), + BackdropImageUrl = string.IsNullOrEmpty(backgroundImageUrl) ? null : backgroundImageUrl, + ThumbImageUrl = string.IsNullOrEmpty(episodeImageUrl) ? null : episodeImageUrl, OfficialRating = string.IsNullOrEmpty(rating) ? null : rating, CommunityRating = starRating is null ? null : (float)starRating.Value, SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) 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 new file mode 100644 index 0000000000..a590193b5f --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs @@ -0,0 +1,33 @@ +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/Recordings/RecordingsMetadataManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs index 3a2c463695..7e68dbb547 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingsMetadataManager.cs @@ -288,7 +288,7 @@ public class RecordingsMetadataManager null, "dateadded", null, - DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); + DateTime.UtcNow.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); if (item.ProductionYear.HasValue) { diff --git a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs index 6a68b8c25c..74fa1415c6 100644 --- a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs +++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs @@ -116,7 +116,7 @@ namespace Jellyfin.LiveTv.Timers throw new ArgumentException("item already exists", nameof(item)); } - _items = [.._items, item]; + _items = [.. _items, item]; SaveList(); } @@ -131,7 +131,7 @@ namespace Jellyfin.LiveTv.Timers int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); if (index == -1) { - _items = [.._items, item]; + _items = [.. _items, item]; } else { diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs index cfd763b6fd..7c16d2b363 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -83,7 +83,7 @@ public class TunerHostManager : ITunerHostManager if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) { info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - config.TunerHosts = [..list, info]; + config.TunerHosts = [.. list, info]; } else { diff --git a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs index fcf37f35d7..6d3ae56f56 100644 --- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs +++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs @@ -60,6 +60,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask DtoOptions = new DtoOptions(true), SourceTypes = [SourceType.Library], Recursive = true, + IncludeOwnedItems = true, Limit = Pagesize }; diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index cbe97a8210..af868e4bd6 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Text; namespace Jellyfin.MediaEncoding.Keyframes.FfProbe; @@ -31,6 +32,7 @@ public static class FfProbeKeyframeExtractor CreateNoWindow = true, UseShellExecute = false, + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, WindowStyle = ProcessWindowStyle.Hidden, diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 0fe2fc43ad..4559f68ce8 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -356,7 +356,7 @@ public class NetworkManager : INetworkManager, IDisposable { lock (_initLock) { - _interfaces = FilterBindSettings(config, _interfaces, IsIPv4Enabled, IsIPv6Enabled).ToList(); + _interfaces = FilterBindSettings(config, _interfaces, IsIPv4Enabled, IsIPv6Enabled).ToList(); } } diff --git a/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs new file mode 100644 index 0000000000..a248664928 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio +// and video segment endpoints are not authenticated. These tests pin down that requests escaping +// the transcode directory are rejected while legitimate ones still serve a file. +public sealed class HlsSegmentControllerTests +{ + private readonly Mock<IFileSystem> _fileSystem = new(); + private readonly Mock<IServerConfigurationManager> _config = new(); + private readonly Mock<ITranscodeManager> _transcodeManager = new(); + private readonly string _transcodePath; + + public HlsSegmentControllerTests() + { + _transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests"); + Directory.CreateDirectory(_transcodePath); + + _config.Setup(c => c.GetConfiguration("encoding")) + .Returns(new EncodingOptions { TranscodingTempPath = _transcodePath }); + _config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of<IApplicationPaths>()); + } + + private HlsSegmentController CreateController(string requestPath) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = requestPath; + + return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [Fact] + public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + var result = controller.GetHlsAudioSegmentLegacy("abc", "segment"); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + [InlineData("subdir/../../../../etc/passwd")] + public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId) + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + // A rooted segment id makes Path.GetFullPath discard the transcode base. + var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd"; + var result = controller.GetHlsAudioSegmentLegacy("abc", rooted); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + // Resolves to "<transcodePath>-evil/passwd", which shares the transcode path as a string prefix. + var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd"); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile() + { + var controller = CreateController("/Videos/abc/hls/list/stream.m3u8"); + + var result = controller.GetHlsPlaylistLegacy("abc", "list"); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Fact] + public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest() + { + // Playlist endpoint serves only .m3u8, even for a path inside the transcode dir. + var controller = CreateController("/Videos/abc/hls/list/stream.mp4"); + + var result = controller.GetHlsPlaylistLegacy("abc", "list"); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId) + { + var controller = CreateController("/Videos/abc/hls/list/stream.m3u8"); + + var result = controller.GetHlsPlaylistLegacy("abc", playlistId); + + Assert.IsType<BadRequestObjectResult>(result); + } + + [Fact] + public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile() + { + _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false)) + .Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") }); + + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts"); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Fact] + public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound() + { + _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false)) + .Returns(Array.Empty<string>()); + + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts"); + + Assert.IsType<NotFoundObjectResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId) + { + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts"); + + Assert.IsType<BadRequestObjectResult>(result); + _fileSystem.Verify(f => f.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()), Times.Never); + } +} diff --git a/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs new file mode 100644 index 0000000000..f040a328bb --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Updates; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath +// must resolve to a file inside the plugin's own directory. +public sealed class PluginsControllerTests +{ + private readonly Mock<IPluginManager> _pluginManager = new(); + private readonly string _pluginPath; + + public PluginsControllerTests() + { + _pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests"); + Directory.CreateDirectory(_pluginPath); + } + + private PluginsController CreateController() => + new PluginsController(Mock.Of<IInstallationManager>(), _pluginManager.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + private void SetupPlugin(Guid id, Version version, string? imagePath) + { + var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath }; + _pluginManager.Setup(p => p.GetPlugin(id, version)) + .Returns(new LocalPlugin(_pluginPath, true, manifest)); + } + + [Fact] + public void GetPluginImage_UnknownPlugin_ReturnsNotFound() + { + var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0)); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public void GetPluginImage_ImageInsidePluginPath_ReturnsFile() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty<byte>()); + SetupPlugin(id, version, "logo.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<PhysicalFileResult>(result); + } + + [Fact] + public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, "does-not-exist.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + [InlineData("subdir/../../../../etc/passwd")] + public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath) + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, imagePath); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + // Resolves to "<pluginPath>-evil/logo.png", which shares the plugin path as a string prefix. + // The file is created so the check fails on the boundary, not on File.Exists. + var siblingDir = _pluginPath + "-evil"; + Directory.CreateDirectory(siblingDir); + File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty<byte>()); + SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath) + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, imagePath); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType<NotFoundResult>(result); + } +} diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs new file mode 100644 index 0000000000..a003be4d96 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Helpers +{ + public class MediaInfoHelperTests + { + private static MediaInfoHelper CreateHelper() + { + return new MediaInfoHelper( + Mock.Of<IUserManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IMediaEncoder>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILogger<MediaInfoHelper>>(), + Mock.Of<INetworkManager>(), + Mock.Of<IDeviceManager>()); + } + + private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true) + { + return new MediaSourceInfo + { + Id = itemId.ToString("N", CultureInfo.InvariantCulture), + Protocol = MediaProtocol.File, + Bitrate = bitrate, + SupportsDirectPlay = supportsDirectPlay, + SupportsDirectStream = true, + SupportsTranscoding = true + }; + } + + [Fact] + public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault() + { + // The version the user was watching (the queried item) must stay the default + // even when a sibling version fits the bitrate limit better, since the resume + // position belongs to that exact version. + var preferredItemId = Guid.NewGuid(); + var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false); + var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + + var result = new PlaybackInfoResponse + { + MediaSources = [siblingSource, preferredSource] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId); + + Assert.Equal(preferredSource.Id, result.MediaSources[0].Id); + } + + [Fact] + public void SortMediaSources_NoPreferredItem_OrdersByPlayability() + { + var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false); + transcodeOnly.SupportsDirectStream = false; + + var result = new PlaybackInfoResponse + { + MediaSources = [transcodeOnly, directPlay] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000); + + Assert.Equal(directPlay.Id, result.MediaSources[0].Id); + } + + [Fact] + public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder() + { + var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false); + transcodeOnly.SupportsDirectStream = false; + + var result = new PlaybackInfoResponse + { + MediaSources = [transcodeOnly, directPlay] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid()); + + Assert.Equal(directPlay.Id, result.MediaSources[0].Id); + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 5c187da413..c0a2b0ecca 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -1,6 +1,15 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using Moq; using Xunit; @@ -46,4 +55,284 @@ public class BaseItemTests Assert.Equal(name, video.GetMediaSourceName(video)); Assert.Equal(altName, video.GetMediaSourceName(videoAlt)); } + + [Theory] + // Episode versions share a season folder; the common prefix (not the folder name) yields the label. + // Both files carry a suffix (no bare base name), so the shared "- " must be stripped too. + [InlineData( + "Spider-Noir - S01E02 - Wo ist Flint - Greyscale", + "Spider-Noir - S01E02 - Wo ist Flint - Colorized", + "Greyscale", + "Colorized")] + // One version is the bare base name; the other is suffixed. + [InlineData( + "Spider-Noir - S01E02 - Wo ist Flint", + "Spider-Noir - S01E02 - Wo ist Flint - Greyscale", + "Spider-Noir - S01E02 - Wo ist Flint", + "Greyscale")] + // Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it. + [InlineData( + "Demo - S01E01 - Greyscale", + "Demo - S01E01 - Greyish", + "Greyscale", + "Greyish")] + // Underscore separator. + [InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")] + // Dot separator. + [InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")] + // Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i". + [InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")] + // A token shared by the descriptors but separated only by spaces (the resolution) must stay in the + // label: retreat to the '-' delimiter, not the interior space, so the resolution is kept. + [InlineData( + "movie (2020) - 2160p Extended", + "movie (2020) - 2160p Original", + "2160p Extended", + "2160p Original")] + // Bracketed version labels: the opening bracket is kept in the label. + [InlineData( + "Blade Runner (1982) [Final Cut] [1080p HEVC AAC]", + "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]", + "[Final Cut] [1080p HEVC AAC]", + "[EE by ADM] [480p HEVC AAC]")] + public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt) + { + var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv"; + var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv"; + var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]); + + var video = new Video() + { + Path = primaryPath + }; + + var videoAlt = new Video() + { + Path = altPath, + }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())) + .Returns((string x) => MediaProtocol.File); + var libraryManager = new Mock<ILibraryManager>(); + // No local alternate versions: these are linked (separate items), so the folder fallback is unavailable. + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())) + .Returns(Array.Empty<Guid>()); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + + Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix)); + Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix)); + } + + [Fact] + public void GetAlternateVersion_ReturnsMatchingLocalVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + Assert.Same(alt1, primary.GetAlternateVersion(alt1.Id)); + Assert.Same(alt2, primary.GetAlternateVersion(alt2.Id)); + Assert.Same(primary, primary.GetAlternateVersion(primary.Id)); + Assert.Null(primary.GetAlternateVersion(Guid.NewGuid())); + } + + [Fact] + public void GetAllVersions_FromAnyVersion_ReturnsEveryVersionOnce() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + foreach (var source in new[] { primary, alt1, alt2 }) + { + var versions = source.GetAllVersions(); + + Assert.Equal(3, versions.Count); + Assert.Contains(versions, v => v.Id.Equals(primary.Id)); + Assert.Contains(versions, v => v.Id.Equals(alt1.Id)); + Assert.Contains(versions, v => v.Id.Equals(alt2.Id)); + } + } + + [Fact] + public void PropagatePlayedState_MarksAlternateVersions_AndResetsPositionByDefault() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var saved = CaptureSaves(); + + var user = new User("test", "default", "default"); + primary.PropagatePlayedState(user, true); + + // Both alternate versions are marked played, the primary (self) is not, and the position is + // reset so a watched version does not linger in "Continue Watching". + Assert.Equal(2, saved.Count); + Assert.DoesNotContain(saved, e => e.ItemId.Equals(primary.Id)); + Assert.Contains(saved, e => e.ItemId.Equals(alt1.Id)); + Assert.Contains(saved, e => e.ItemId.Equals(alt2.Id)); + Assert.All(saved, e => + { + Assert.True(e.Dto.Played.GetValueOrDefault()); + Assert.Equal(0, e.Dto.PlaybackPositionTicks); + }); + } + + [Fact] + public void PropagatePlayedState_WithoutReset_LeavesPositionUntouched() + { + var (primary, _, _) = SetupVersionGroup(); + + var saved = CaptureSaves(); + + primary.PropagatePlayedState(new User("test", "default", "default"), true, resetPosition: false); + + Assert.Equal(2, saved.Count); + Assert.All(saved, e => + { + Assert.True(e.Dto.Played.GetValueOrDefault()); + Assert.Null(e.Dto.PlaybackPositionTicks); + }); + } + + [Fact] + public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + // Each alternate starts out watched, with a play count, resume point and last-played date. + var existing = new Dictionary<Guid, UserItemData> + { + [alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, + [alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) }, + }; + + var saved = new List<UserItemData>(); + var userDataManager = new Mock<IUserDataManager>(); + userDataManager.Setup(x => x.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())) + .Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id)); + userDataManager + .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<UserDataSaveReason>(), It.IsAny<CancellationToken>())) + .Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>((_, _, data, _, _) => saved.Add(data)); + BaseItem.UserDataManager = userDataManager.Object; + + primary.PropagatePlayedState(new User("test", "default", "default"), false); + + // Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag, + // play count, resume point and last-played date are all cleared so no watched state lingers. + Assert.Equal(2, saved.Count); + Assert.All(saved, d => + { + Assert.False(d.Played); + Assert.Equal(0, d.PlayCount); + Assert.Equal(0, d.PlaybackPositionTicks); + Assert.Null(d.LastPlayedDate); + }); + } + + private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves() + { + var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>(); + var userDataManager = new Mock<IUserDataManager>(); + userDataManager + .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>())) + .Callback<User, BaseItem, UpdateUserItemDataDto, UserDataSaveReason>((_, item, dto, _) => saved.Add((item.Id, dto))); + BaseItem.UserDataManager = userDataManager.Object; + return saved; + } + + [Fact] + public void PropagatePlayedState_SingleVersion_DoesNothing() + { + var solo = new Video { Id = Guid.NewGuid(), Path = "/Movies/Solo/Solo.mkv" }; + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + + var userDataManager = new Mock<IUserDataManager>(); + BaseItem.UserDataManager = userDataManager.Object; + + solo.PropagatePlayedState(new User("test", "default", "default"), true); + + userDataManager.Verify( + x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()), + Times.Never); + } + + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() + { + var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; + var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id }; + var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id }; + + // 2160p primary, 1080p alternates: width is only the ordering tiebreaker, set so it would place + // the primary first — letting the tests confirm the queried version's own source still wins. + var widths = new Dictionary<Guid, int> { [primary.Id] = 3840, [alt1.Id] = 1920, [alt2.Id] = 1920 }; + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())) + .Returns((Guid id) => new List<MediaStream> { new MediaStream { Type = MediaStreamType.Video, Width = widths.GetValueOrDefault(id) } }); + mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>()); + + var segmentManager = new Mock<IMediaSegmentManager>(); + segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false); + BaseItem.MediaSegmentManager = segmentManager.Object; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id }); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1); + libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2); + libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary); + + var recordingsManager = new Mock<IRecordingsManager>(); + recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null); + Video.RecordingsManager = recordingsManager.Object; + + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + + return (primary, alt1, alt2); + } + + [Fact] + public void GetMediaSources_DefaultsToTheQueriedVersionsOwnSource() + { + var (primary, alt1, _) = SetupVersionGroup(); + + // Resuming the 1080p alternate must default to the 1080p source, not the higher-resolution + // 2160p primary that the width ordering would otherwise place first. + Assert.Equal(alt1.Id.ToString("N"), alt1.GetMediaSources(false)[0].Id); + + // Opening the primary still defaults to the primary's own (here highest-resolution) source. + Assert.Equal(primary.Id.ToString("N"), primary.GetMediaSources(false)[0].Id); + } + + [Fact] + public void GetAllItemsForMediaSources_FromAnyVersion_HasNoDuplicates() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetAllItemsForMediaSources", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Each version must surface exactly once, regardless of which member the list is built from. + // Building from an alternate previously re-added that alternate as a "local alternate" of the + // primary, producing a duplicate entry in the version dropdown. + foreach (var source in new[] { primary, alt1, alt2 }) + { + var items = (IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)>)method!.Invoke(source, null)!; + var ids = items.Select(i => i.Item.Id).ToList(); + + Assert.Equal(3, ids.Count); + Assert.Equal(ids.Count, ids.Distinct().Count()); + Assert.Contains(primary.Id, ids); + Assert.Contains(alt1.Id, ids); + Assert.Contains(alt2.Id, ids); + } + } } diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs new file mode 100644 index 0000000000..7d87d5ee92 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs @@ -0,0 +1,89 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using Xunit; + +namespace Jellyfin.Controller.Tests.Library; + +public class VersionResumeDataTests +{ + [Fact] + public void ApplyTo_CompletedOtherVersion_PropagatesCompletionAndClearsStaleResume() + { + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var resume = new VersionResumeData( + Guid.NewGuid(), + new UserItemData { Key = "version", PlaybackPositionTicks = 0, Played = true, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 }; + + resume.ApplyTo(dto); + + // Completion state propagates to the primary... + Assert.True(dto.Played); + Assert.Equal(lastPlayed, dto.LastPlayedDate); + + // ...and because the movie was finished on a different version, the primary's own stale resume bar is cleared. + Assert.Equal(0, dto.PlaybackPositionTicks); + Assert.Null(dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_PrimaryOwnProgress_KeepsResumePosition() + { + var primaryId = Guid.NewGuid(); + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + + // The winning version is the primary itself (e.g. rewatching): its resume bar must survive. + var resume = new VersionResumeData( + primaryId, + new UserItemData { Key = "primary", PlaybackPositionTicks = 5, Played = true, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = primaryId, Key = "primary", PlaybackPositionTicks = 5, Played = true, PlayedPercentage = 20 }; + + resume.ApplyTo(dto); + + Assert.True(dto.Played); + Assert.Equal(5, dto.PlaybackPositionTicks); + Assert.Equal(20, dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_InProgressOtherVersion_KeepsPrimaryResumePosition() + { + var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + + // A different version that is in-progress (not finished) must not clear the primary's position. + var resume = new VersionResumeData( + Guid.NewGuid(), + new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = false, LastPlayedDate = lastPlayed }); + + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 }; + + resume.ApplyTo(dto); + + Assert.False(dto.Played); + Assert.Equal(1, dto.PlaybackPositionTicks); + Assert.Equal(50, dto.PlayedPercentage); + } + + [Fact] + public void ApplyTo_DoesNotUnsetExistingPlayedOrRegressLastPlayed() + { + var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc); + var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var resume = new VersionResumeData( + Guid.NewGuid(), + new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed }); + + var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed }; + + resume.ApplyTo(dto); + + // A not-yet-completed version must not clear the primary's own completion, and the more recent + // LastPlayedDate is kept. + Assert.True(dto.Played); + Assert.Equal(primaryLastPlayed, dto.LastPlayedDate); + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs new file mode 100644 index 0000000000..2dcb898051 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperAudioBitStreamTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; +using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; + +namespace Jellyfin.Controller.Tests.MediaEncoding +{ + public class EncodingHelperAudioBitStreamTests + { + private const string BothFilters = " -bsf:a noise=drop='lt(pts*tb\\,63.063)',aac_adtstoasc"; + private const string NoiseOnly = " -bsf:a noise=drop='lt(pts*tb\\,63.063)'"; + private const string AdtsOnly = " -bsf:a aac_adtstoasc"; + private const long DefaultSeekTicks = 630_630_000L; + private const string DefaultFfmpegVersion = "5.0"; + + private static EncodingHelper CreateHelper(string ffmpegVersion) + { + var mediaEncoder = new Mock<IMediaEncoder>(); + mediaEncoder + .Setup(e => e.GetTimeParameter(It.IsAny<long>())) + .Returns((long ticks) => TimeSpan.FromTicks(ticks).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture)); + mediaEncoder + .SetupGet(e => e.EncoderVersion) + .Returns(Version.Parse(ffmpegVersion)); + + return new EncodingHelper( + Mock.Of<IApplicationPaths>(), + mediaEncoder.Object, + Mock.Of<ISubtitleEncoder>(), + Mock.Of<IConfiguration>(), + Mock.Of<IConfigurationManager>(), + Mock.Of<IPathManager>()); + } + + private static EncodingJobInfo CreateState( + TranscodingJobType jobType, + string outputVideoCodec, + string outputAudioCodec, + string audioStreamCodec, + string inputContainer, + long startTimeTicks) + { + return new EncodingJobInfo(jobType) + { + IsVideoRequest = true, + OutputVideoCodec = outputVideoCodec, + OutputAudioCodec = outputAudioCodec, + InputContainer = inputContainer, + RunTimeTicks = TimeSpan.FromMinutes(10).Ticks, + AudioStream = new MediaStream + { + Type = MediaStreamType.Audio, + Codec = audioStreamCodec + }, + BaseRequest = new BaseEncodingJobOptions + { + StartTimeTicks = startTimeTicks + } + }; + } + + [Theory] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", BothFilters)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "aac", BothFilters)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "hls", BothFilters)] + [InlineData(TranscodingJobType.Progressive, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "copy", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "aac", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "wtv", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", 0L, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, "4.4.6", "mp4", "ts", AdtsOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "ts", "ts", NoiseOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "mkv", NoiseOnly)] + [InlineData(TranscodingJobType.Hls, "libx264", "copy", "ac3", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", NoiseOnly)] + public void AudioBitStreamArguments_AppliesGates( + TranscodingJobType jobType, + string outputVideoCodec, + string outputAudioCodec, + string audioStreamCodec, + string inputContainer, + long startTicks, + string ffmpegVersion, + string segmentContainer, + string mediaSourceContainer, + string expected) + { + var state = CreateState(jobType, outputVideoCodec, outputAudioCodec, audioStreamCodec, inputContainer, startTicks); + var result = CreateHelper(ffmpegVersion).GetAudioBitStreamArguments(state, segmentContainer, mediaSourceContainer); + Assert.Equal(expected, result); + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs new file mode 100644 index 0000000000..71b6551d0f --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using Moq; +using Xunit; + +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; + +namespace Jellyfin.Controller.Tests.MediaEncoding; + +public class EncodingHelperTests +{ + [Fact] + public void GetMapArgs_NoSubtitle_ExcludesAllSubs() + { + var state = BuildState(subtitle: null, deliveryMethod: null); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map -0:s", args, StringComparison.Ordinal); + Assert.DoesNotContain("-map 1:", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_InternalSrt_MapsFromPrimaryInput() + { + var sub = new MediaStream { Index = 2, Type = MediaStreamType.Subtitle, Codec = "srt" }; + var state = BuildState(sub, SubtitleDeliveryMethod.Embed); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 0:2", args, StringComparison.Ordinal); + Assert.DoesNotContain("-map 1:", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_InternalSubAtHigherIndex_MapsCorrectIndex() + { + var sub0 = new MediaStream { Index = 2, Type = MediaStreamType.Subtitle, Codec = "srt" }; + var sub1 = new MediaStream { Index = 3, Type = MediaStreamType.Subtitle, Codec = "ass" }; + var state = BuildState(sub1, SubtitleDeliveryMethod.Embed, additionalStreams: [sub0, sub1]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 0:3", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_ExternalSrt_MapsFirstStreamFromInput1() + { + var sub = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "srt", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.en.srt" + }; + var state = BuildState(sub, SubtitleDeliveryMethod.Embed); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:0", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_SecondExternalSrt_StillMaps1Colon0() + { + // Two separate .srt files — selecting the second one still maps 1:0 + // because Jellyfin feeds only the selected file as ffmpeg input 1. + var ext1 = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "srt", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.en.srt" + }; + var ext2 = new MediaStream + { + Index = 3, + Type = MediaStreamType.Subtitle, + Codec = "srt", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.fr.srt" + }; + var state = BuildState(ext2, SubtitleDeliveryMethod.Embed, additionalStreams: [ext1, ext2]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:0", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_MksFirstTrack_MapsInFileIndex0() + { + var mks0 = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "subrip", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var mks1 = new MediaStream + { + Index = 3, + Type = MediaStreamType.Subtitle, + Codec = "ass", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var state = BuildState(mks0, SubtitleDeliveryMethod.Embed, additionalStreams: [mks0, mks1]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:0", args, StringComparison.Ordinal); + } + + [Fact] + public void GetMapArgs_MksSecondTrack_MapsInFileIndex1() + { + var mks0 = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "subrip", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var mks1 = new MediaStream + { + Index = 3, + Type = MediaStreamType.Subtitle, + Codec = "ass", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var mks2 = new MediaStream + { + Index = 4, + Type = MediaStreamType.Subtitle, + Codec = "subrip", + IsExternal = true, + SupportsExternalStream = true, + Path = "/media/movie.mks" + }; + var state = BuildState(mks1, SubtitleDeliveryMethod.Embed, additionalStreams: [mks0, mks1, mks2]); + var args = CreateHelper().GetMapArgs(state); + + Assert.Contains("-map 1:1", args, StringComparison.Ordinal); + } + + [Theory] + [InlineData(SubtitleDeliveryMethod.Embed, true, "movie.idx")] + [InlineData(SubtitleDeliveryMethod.Encode, true, "movie.idx")] + [InlineData(SubtitleDeliveryMethod.Embed, false, "movie.sub")] + [InlineData(SubtitleDeliveryMethod.Encode, false, "movie.sub")] + public void GetInputArgument_VobSub_UsesCorrectPath( + SubtitleDeliveryMethod deliveryMethod, + bool createIdxFile, + string expectedFilename) + { + var tempDir = Directory.CreateTempSubdirectory("jellyfin-test-"); + try + { + var subFile = Path.Combine(tempDir.FullName, "movie.sub"); + File.WriteAllText(subFile, "dummy"); + + if (createIdxFile) + { + File.WriteAllText(Path.Combine(tempDir.FullName, "movie.idx"), "dummy"); + } + + var sub = new MediaStream + { + Index = 2, + Type = MediaStreamType.Subtitle, + Codec = "dvdsub", + IsExternal = true, + SupportsExternalStream = true, + Path = subFile + }; + var state = BuildState(sub, deliveryMethod); + var inputArgs = CreateHelper().GetInputArgument(state, new EncodingOptions(), null); + + Assert.Contains(expectedFilename, inputArgs, StringComparison.Ordinal); + } + finally + { + tempDir.Delete(true); + } + } + + [Theory] + [InlineData("aac", 44100, 44100)] // non-opus: requested rate must be preserved (issue #17026) + [InlineData("aac", 48000, 48000)] + [InlineData("mp3", 22050, 22050)] + [InlineData("flac", 96000, 96000)] + [InlineData("opus", 44100, 48000)] // opus: must snap to a libopus-supported rate + [InlineData("opus", 22050, 24000)] + [InlineData("opus", 8000, 8000)] + public void GetProgressiveAudioFullCommandLine_SampleRate_OnlyClampedForOpus( + string audioCodec, + int requestedSampleRate, + int expectedSampleRate) + { + var state = BuildAudioState(audioCodec, requestedSampleRate); + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal); + } + + private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate) + { + var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 }; + + return new EncodingJobInfo(TranscodingJobType.Progressive) + { + MediaSource = new MediaSourceInfo + { + Container = "flac", + MediaStreams = new List<MediaStream> { audio }, + Path = "/media/track.flac", + Protocol = MediaProtocol.File, + }, + AudioStream = audio, + OutputAudioCodec = audioCodec, + BaseRequest = new VideoRequestDto + { + AudioCodec = audioCodec, + AudioSampleRate = requestedSampleRate, + }, + IsVideoRequest = false, + IsInputVideo = false, + }; + } + + private static EncodingJobInfo BuildState( + MediaStream? subtitle, + SubtitleDeliveryMethod? deliveryMethod, + MediaStream[]? additionalStreams = null) + { + var video = new MediaStream { Index = 0, Type = MediaStreamType.Video, Codec = "h264" }; + var audio = new MediaStream { Index = 1, Type = MediaStreamType.Audio, Codec = "aac" }; + var streams = new List<MediaStream> { video, audio }; + + if (additionalStreams is not null) + { + streams.AddRange(additionalStreams); + } + else if (subtitle is not null) + { + streams.Add(subtitle); + } + + return new EncodingJobInfo(TranscodingJobType.Progressive) + { + MediaSource = new MediaSourceInfo + { + Container = "mkv", + MediaStreams = streams, + }, + VideoStream = video, + AudioStream = audio, + SubtitleStream = subtitle, + SubtitleDeliveryMethod = deliveryMethod ?? SubtitleDeliveryMethod.Drop, + BaseRequest = new VideoRequestDto(), + IsVideoRequest = true, + IsInputVideo = true, + }; + } + + private static EncodingHelper CreateHelper() + { + var appPaths = Mock.Of<IApplicationPaths>(); + var mediaEncoder = new Mock<IMediaEncoder>(); + var subtitleEncoder = new Mock<ISubtitleEncoder>(); + var config = new Mock<IConfiguration>(); + var configurationManager = new Mock<IConfigurationManager>(); + var pathManager = new Mock<IPathManager>(); + + return new EncodingHelper( + appPaths, + mediaEncoder.Object, + subtitleEncoder.Object, + config.Object, + configurationManager.Object, + pathManager.Object); + } +} diff --git a/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs new file mode 100644 index 0000000000..cdbf2f8b1d --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs @@ -0,0 +1,397 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Extensions.Tests; + +public class StreamExtensionsTests +{ + [Fact] + public async Task IsStreamIdenticalAsync_SeekableDifferentLengths_ReturnsFalse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new MemoryStream(new byte[] { 1, 2, 3 }); + await using var b = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.False(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableIdenticalStreams_ReturnsTrue() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableDifferentStreams_ReturnsFalse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 9, 4 }); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.False(result); + } + + [Fact] + public async Task IsFileIdenticalAsync_NonSeekableStream_ThrowsArgumentException() + { + var cancellationToken = TestContext.Current.CancellationToken; + var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + await File.WriteAllBytesAsync(path, new byte[] { 1, 2, 3, 4 }, cancellationToken); + + try + { + await using var stream = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + + await Assert.ThrowsAsync<ArgumentException>(async () => + await stream.IsFileIdenticalAsync(path, cancellationToken)); + } + finally + { + File.Delete(path); + } + } + + // Both publiclyVisible values are exercised so the test runs once under the fast path + // (TryGetBuffer succeeds) and once under the slow path (TryGetBuffer returns false). + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsFileIdenticalAsync_UsesStartOfStreamAndRestoresPosition_OnMatch(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var bytes = new byte[] { 10, 20, 30, 40, 50 }; + await File.WriteAllBytesAsync(path, bytes, cancellationToken); + + try + { + await using var stream = CreateMemoryStream(bytes, publiclyVisible); + stream.Position = 3; + + var result = await stream.IsFileIdenticalAsync(path, cancellationToken); + + Assert.True(result); + Assert.Equal(3, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsFileIdenticalAsync_RestoresPosition_OnMismatch(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + await File.WriteAllBytesAsync(path, new byte[] { 10, 20, 30, 40, 99 }, cancellationToken); + + try + { + await using var stream = CreateMemoryStream(new byte[] { 10, 20, 30, 40, 50 }, publiclyVisible); + stream.Position = 2; + + var result = await stream.IsFileIdenticalAsync(path, cancellationToken); + + Assert.False(result); + Assert.Equal(2, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsStreamIdenticalAsync_BothMemoryStreams_NonZeroPositions_SeeksToStart(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible); + await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible); + a.Position = 3; + b.Position = 1; + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsStreamIdenticalAsync_MemoryStreamPairedWithSeekableNonMemoryStream_NonZeroPositions_SeeksToStart(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible); + await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + a.Position = 2; + b.Position = 3; + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task IsStreamIdenticalAsync_NonMemoryStreamPairedWithMemoryStream_Swaps_ReturnsTrue(bool publiclyVisible) + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_BothSeekableNonMemoryStreams_NonZeroPositions_SeeksToStart() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 }); + a.Position = 1; + b.Position = 2; + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableShortReads_Identical_ReturnsTrue() + { + var cancellationToken = TestContext.Current.CancellationToken; + var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + await using var a = new ShortReadingNonSeekableStream(data, maxReadSize: 3); + await using var b = new ShortReadingNonSeekableStream(data, maxReadSize: 5); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task IsStreamIdenticalAsync_NonSeekableShortReads_DifferentLengths_ReturnsFalse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var a = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4 }, maxReadSize: 3); + await using var b = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4, 5 }, maxReadSize: 5); + + var result = await a.IsStreamIdenticalAsync(b, cancellationToken); + + Assert.False(result); + } + + private static MemoryStream CreateMemoryStream(byte[] data, bool publiclyVisible) + => publiclyVisible + ? new MemoryStream(data, 0, data.Length, writable: false, publiclyVisible: true) + : new MemoryStream(data); + + private sealed class NonSeekableReadStream : Stream + { + private readonly Stream _inner; + + public NonSeekableReadStream(byte[] data) + { + _inner = new MemoryStream(data, writable: false); + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, count); + + public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer, cancellationToken); + + public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } + + private sealed class SeekableNonMemoryStream : Stream + { + private readonly MemoryStream _inner; + + public SeekableNonMemoryStream(byte[] data) + { + _inner = new MemoryStream(data, writable: false); + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => _inner.Length; + + public override long Position + { + get => _inner.Position; + set => _inner.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, count); + + public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer, cancellationToken); + + public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override long Seek(long offset, SeekOrigin origin) + => _inner.Seek(offset, origin); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } + + private sealed class ShortReadingNonSeekableStream : Stream + { + private readonly Stream _inner; + private readonly int _maxReadSize; + + public ShortReadingNonSeekableStream(byte[] data, int maxReadSize) + { + _inner = new MemoryStream(data, writable: false); + _maxReadSize = maxReadSize; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, Math.Min(count, _maxReadSize)); + + public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxReadSize)], cancellationToken); + + public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inner.ReadAsync(buffer.AsMemory(offset, Math.Min(count, _maxReadSize)), cancellationToken).AsTask(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs index b71dc15201..f698edc637 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,60 @@ public class XmlTvListingsProviderTests var program = programsList[0]; Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); + } + + [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 new file mode 100644 index 0000000000..f44cb88834 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs @@ -0,0 +1,51 @@ +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/Recordings/RecordingsMetadataManagerTests.cs b/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs new file mode 100644 index 0000000000..14ce470fb4 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Recordings/RecordingsMetadataManagerTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using System.Xml; +using Jellyfin.Extensions; +using Jellyfin.LiveTv.Recordings; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Recordings; + +public sealed class RecordingsMetadataManagerTests +{ + private readonly string _tempDir = + Path.Combine(Path.GetTempPath(), "jellyfin-test-" + Guid.NewGuid()); + + [Fact] + public async Task SaveRecordingMetadata_DateAddedIsUtc() + { + Directory.CreateDirectory(_tempDir); + var recordingPath = Path.Combine(_tempDir, "test-recording.ts"); + FileHelper.CreateEmpty(recordingPath); + + var config = new Mock<IConfigurationManager>(); + config.Setup(c => c.GetConfiguration("livetv")) + .Returns(new LiveTvOptions { SaveRecordingNFO = true, SaveRecordingImages = false }); + config.Setup(c => c.GetConfiguration("xbmcmetadata")) + .Returns(new XbmcMetadataOptions()); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager + .Setup(l => l.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(Array.Empty<BaseItem>()); + + var manager = new RecordingsMetadataManager( + NullLogger<RecordingsMetadataManager>.Instance, + config.Object, + libraryManager.Object); + + var timer = new TimerInfo { Name = "Test Recording", ProgramId = null }; + + var beforeUtc = DateTime.UtcNow.AddSeconds(-2); + await manager.SaveRecordingMetadata(timer, recordingPath, null); + var afterUtc = DateTime.UtcNow.AddSeconds(2); + + var doc = new XmlDocument(); + doc.Load(Path.ChangeExtension(recordingPath, ".nfo")); + var dateAddedText = doc.SelectSingleNode("//dateadded")?.InnerText ?? string.Empty; + var parsed = DateTime.ParseExact( + dateAddedText, + "yyyy-MM-dd HH:mm:ss", + CultureInfo.InvariantCulture); + + Assert.InRange(parsed, beforeUtc, afterUtc); + } +} 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/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/ApplePlatformHelperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs new file mode 100644 index 0000000000..9847acbb0a --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs @@ -0,0 +1,22 @@ +using System; +using System.Runtime.Versioning; +using MediaBrowser.MediaEncoding.Encoder; +using Xunit; + +namespace Jellyfin.MediaEncoding.Tests; + +[SupportedOSPlatform("macos")] +public class ApplePlatformHelperTests +{ + [Fact] + public void GetSysctlValue_CpuBrand_NotEmpty() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "macOS-only test"); + + var value = ApplePlatformHelper.GetSysctlValue("machdep.cpu.brand_string"); + Assert.NotEmpty(value); + + // Make sure we don't include the null terminator + Assert.DoesNotContain("\0", value, StringComparison.Ordinal); + } +} 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 198cdaa4fc..b723fc7208 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using System.Text.Json; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; @@ -56,6 +57,43 @@ namespace Jellyfin.MediaEncoding.Tests.Probing public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected) => Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar)); + [Theory] + // Lossy codecs, mono/stereo and multichannel. + [InlineData("aac", null, 2, 192000)] + [InlineData("mp3", null, 2, 192000)] + [InlineData("mp2", null, 2, 192000)] + [InlineData("aac", null, 6, 320000)] + [InlineData("ac3", null, 2, 192000)] + [InlineData("eac3", null, 6, 640000)] + [InlineData("opus", null, 2, 128000)] + [InlineData("vorbis", null, 6, 320000)] + [InlineData("wmav2", null, 2, 192000)] + // DTS: the lossy core (any non-MA profile, or none) is flat and caps at 5.1... + [InlineData("dts", null, 2, 768000)] + [InlineData("dts", "DTS", 6, 1509000)] + [InlineData("dts", "DTS-HD HRA", 8, 1509000)] + // ...while lossless DTS-HD MA scales per channel like other lossless codecs. + [InlineData("dts", "DTS-HD MA", 6, 4200000)] + [InlineData("dts", "DTS-HD MA + DTS:X", 8, 5600000)] + // Lossless codecs scale per channel. + [InlineData("flac", null, 2, 960000)] + [InlineData("flac", null, 6, 2880000)] + [InlineData("flac", null, 8, 3840000)] + [InlineData("alac", null, 6, 2880000)] + [InlineData("truehd", null, 2, 1400000)] + [InlineData("truehd", null, 6, 4200000)] + [InlineData("truehd", "Dolby TrueHD + Dolby Atmos", 8, 5600000)] + // 3-4 channel audio must use the multichannel estimate, not return null. + [InlineData("aac", null, 3, 320000)] + [InlineData("ac3", null, 4, 640000)] + // Codec matching is case-insensitive. + [InlineData("AAC", null, 2, 192000)] + // Unknown codec or unknown channel count cannot be estimated. + [InlineData("pcm_s16le", null, 2, null)] + [InlineData("aac", null, null, null)] + public void GetEstimatedAudioBitrate_ReturnsExpected(string codec, string? profile, int? channels, int? expected) + => Assert.Equal(expected, ProbeResultNormalizer.GetEstimatedAudioBitrate(codec, profile, channels)); + [Fact] public void GetMediaInfo_MetaData_Success() { @@ -71,7 +109,10 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("4:3", res.VideoStream.AspectRatio); Assert.Equal(25f, res.VideoStream.AverageFrameRate); Assert.Equal(8, res.VideoStream.BitDepth); - Assert.Equal(69432, res.VideoStream.BitRate); + // ffprobe reports no per-stream video bitrate here. The container bitrate must not be + // misreported as the video bitrate, and the other streams' bitrates exceed the container + // bitrate in this sample, so no sensible video bitrate can be inferred (see #16248). + Assert.Null(res.VideoStream.BitRate); Assert.Equal("h264", res.VideoStream.Codec); Assert.Equal("1/50", res.VideoStream.CodecTimeBase); Assert.Equal(240, res.VideoStream.Height); @@ -322,6 +363,73 @@ namespace Jellyfin.MediaEncoding.Tests.Probing } [Fact] + public void GetMediaInfo_MissingVideoBitrate_EstimatedFromContainer() + { + // ffprobe did not report a per-stream video bitrate. The video bitrate must be estimated + // as the container bitrate minus the other (audio) stream bitrates, not reported as the + // whole container bitrate (see #16248). + var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate.mp4", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + + var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio); + Assert.Equal(128000, audioStream.BitRate); + + // Container bitrate (5128000) minus the audio bitrate (128000). + Assert.Equal(5000000, res.VideoStream.BitRate); + + // The container bitrate itself must remain the overall container bitrate. + Assert.Equal(5128000, res.Bitrate); + } + + [Fact] + public void GetMediaInfo_NanosecondDurationTag_BitrateComputedFromBytes() + { + // The stream carries NUMBER_OF_BYTES and a nanosecond-precision DURATION tag but no + // bitrate. TimeSpan only supports 7 fractional digits, so the 9-digit DURATION must be + // trimmed for the duration to parse and the bitrate to be computed (bytes * 8 / seconds). + var bytes = File.ReadAllBytes("Test Data/Probing/video_nanosecond_duration_bitrate.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_nanosecond_duration_bitrate.mkv", MediaProtocol.File); + + Assert.NotNull(res.VideoStream); + + // 10000000 bytes * 8 / 100 seconds. + Assert.Equal(800000, res.VideoStream.BitRate); + } + + [Fact] + public void GetMediaInfo_MissingVideoBitrate_UnknownAudioBitrate_NotEstimated() + { + // ffprobe reported no per-stream video bitrate and the audio bitrate cannot be estimated + // (the audio stream has no channel count, so GetEstimatedAudioBitrate returns null). The + // video bitrate must be left unset rather than wrongly absorbing the unaccounted audio + // bitrate (see #16248). + var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate_unknown_audio.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate_unknown_audio.mp4", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Null(res.VideoStream.BitRate); + + var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio); + Assert.Null(audioStream.BitRate); + + // The overall container bitrate is still reported. + Assert.Equal(5128000, res.Bitrate); + } + + [Fact] public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success() { var bytes = File.ReadAllBytes("Test Data/Probing/video_single_frame_mjpeg.json"); 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/FilterEventsTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs deleted file mode 100644 index 5f84e85592..0000000000 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using AutoFixture; -using AutoFixture.AutoMoq; -using MediaBrowser.MediaEncoding.Subtitles; -using MediaBrowser.Model.MediaInfo; -using Xunit; - -namespace Jellyfin.MediaEncoding.Subtitles.Tests -{ - public class FilterEventsTests - { - private readonly SubtitleEncoder _encoder; - - public FilterEventsTests() - { - var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); - _encoder = fixture.Create<SubtitleEncoder>(); - } - - [Fact] - public void FilterEvents_SubtitleSpanningSegmentBoundary_IsRetained() - { - // Subtitle starts at 5s, ends at 15s. - // Segment requested from 10s to 20s. - // The subtitle is still on screen at 10s and should NOT be dropped. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Still on screen") - { - StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("2", "Next subtitle") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(17).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Equal(2, track.TrackEvents.Count); - Assert.Equal("1", track.TrackEvents[0].Id); - Assert.Equal("2", track.TrackEvents[1].Id); - } - - [Fact] - public void FilterEvents_SubtitleFullyBeforeSegment_IsDropped() - { - // Subtitle starts at 2s, ends at 5s. - // Segment requested from 10s. - // The subtitle ended before the segment — should be dropped. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Already gone") - { - StartPositionTicks = TimeSpan.FromSeconds(2).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(5).Ticks - }, - new SubtitleTrackEvent("2", "Visible") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(17).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Single(track.TrackEvents); - Assert.Equal("2", track.TrackEvents[0].Id); - } - - [Fact] - public void FilterEvents_SubtitleAfterSegment_IsDropped() - { - // Segment is 10s-20s, subtitle starts at 25s. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "In range") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("2", "After segment") - { - StartPositionTicks = TimeSpan.FromSeconds(25).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(30).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Single(track.TrackEvents); - Assert.Equal("1", track.TrackEvents[0].Id); - } - - [Fact] - public void FilterEvents_PreserveTimestampsFalse_AdjustsTimestamps() - { - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Subtitle") - { - StartPositionTicks = TimeSpan.FromSeconds(15).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(20).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(30).Ticks, - preserveTimestamps: false); - - Assert.Single(track.TrackEvents); - // Timestamps should be shifted back by 10s - Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].StartPositionTicks); - Assert.Equal(TimeSpan.FromSeconds(10).Ticks, track.TrackEvents[0].EndPositionTicks); - } - - [Fact] - public void FilterEvents_PreserveTimestampsTrue_KeepsOriginalTimestamps() - { - var startTicks = TimeSpan.FromSeconds(15).Ticks; - var endTicks = TimeSpan.FromSeconds(20).Ticks; - - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Subtitle") - { - StartPositionTicks = startTicks, - EndPositionTicks = endTicks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(30).Ticks, - preserveTimestamps: true); - - Assert.Single(track.TrackEvents); - Assert.Equal(startTicks, track.TrackEvents[0].StartPositionTicks); - Assert.Equal(endTicks, track.TrackEvents[0].EndPositionTicks); - } - - [Fact] - public void FilterEvents_SubtitleEndingExactlyAtSegmentStart_IsRetained() - { - // Subtitle ends exactly when the segment begins. - // EndPositionTicks == startPositionTicks means (end - start) == 0, not < 0, - // so SkipWhile stops and the subtitle is retained. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Boundary subtitle") - { - StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(10).Ticks - }, - new SubtitleTrackEvent("2", "In range") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Equal(2, track.TrackEvents.Count); - Assert.Equal("1", track.TrackEvents[0].Id); - } - - [Fact] - public void FilterEvents_SpanningBoundaryWithTimestampAdjustment_DoesNotProduceNegativeTimestamps() - { - // Subtitle starts at 5s, ends at 15s. - // Segment requested from 10s to 20s, preserveTimestamps = false. - // The subtitle spans the boundary and is retained, but shifting - // StartPositionTicks by -10s would produce -5s (negative). - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Spans boundary") - { - StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("2", "Fully in range") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(17).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: false); - - Assert.Equal(2, track.TrackEvents.Count); - // Subtitle 1: start should be clamped to 0, not -5s - Assert.True(track.TrackEvents[0].StartPositionTicks >= 0, "StartPositionTicks must not be negative"); - Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].EndPositionTicks); - // Subtitle 2: normal shift (12s - 10s = 2s, 17s - 10s = 7s) - Assert.Equal(TimeSpan.FromSeconds(2).Ticks, track.TrackEvents[1].StartPositionTicks); - Assert.Equal(TimeSpan.FromSeconds(7).Ticks, track.TrackEvents[1].EndPositionTicks); - } - - [Fact] - public void FilterEvents_NoEndTimeTicks_ReturnsAllFromStartPosition() - { - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Before") - { - StartPositionTicks = TimeSpan.FromSeconds(2).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(4).Ticks - }, - new SubtitleTrackEvent("2", "After") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("3", "Much later") - { - StartPositionTicks = TimeSpan.FromSeconds(500).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(505).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: 0, - preserveTimestamps: true); - - Assert.Equal(2, track.TrackEvents.Count); - Assert.Equal("2", track.TrackEvents[0].Id); - Assert.Equal("3", track.TrackEvents[1].Id); - } - } -} 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..2d0fa29c9a 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,12 +11,16 @@ 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 { + private const int StreamCount = 8; + private const int CueCount = 500; + public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData() { var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>(); @@ -103,5 +112,90 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Assert.Equal(subtitleInfo.Format, result.Format); Assert.Equal(subtitleInfo.IsExternal, result.IsExternal); } + + [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 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_missing_video_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json new file mode 100644 index 0000000000..803a3a7e5f --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json @@ -0,0 +1,113 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1/48", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "closed_captions": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/12288", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3686400, + "duration": "300.000000", + "bits_per_raw_sample": "8", + "nb_frames": "7200", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler" + } + }, + { + "index": 1, + "codec_name": "aac", + "codec_long_name": "AAC (Advanced Audio Coding)", + "profile": "LC", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "mp4a", + "codec_tag": "0x6134706d", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 14400000, + "duration": "300.000000", + "bit_rate": "128000", + "nb_frames": "14063", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng", + "handler_name": "SoundHandler" + } + } + ], + "format": { + "filename": "test.1080p.mp4", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "192000000", + "bit_rate": "5128000", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json new file mode 100644 index 0000000000..ff6dc51f27 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json @@ -0,0 +1,110 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1/48", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "closed_captions": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/12288", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3686400, + "duration": "300.000000", + "bits_per_raw_sample": "8", + "nb_frames": "7200", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler" + } + }, + { + "index": 1, + "codec_name": "dts", + "codec_long_name": "DCA (DTS Coherent Acoustics)", + "profile": "DTS-HD MA", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "s32p", + "sample_rate": "48000", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 14400000, + "duration": "300.000000", + "nb_frames": "14063", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng", + "handler_name": "SoundHandler" + } + } + ], + "format": { + "filename": "test.1080p.mp4", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "192000000", + "bit_rate": "5128000", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json new file mode 100644 index 0000000000..ff8b2ca80a --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json @@ -0,0 +1,49 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1080, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "r_frame_rate": "24/1", + "avg_frame_rate": "24/1", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "disposition": { + "default": 1 + }, + "tags": { + "language": "eng", + "BPS-eng": "", + "DURATION-eng": "00:01:40.000000000", + "NUMBER_OF_FRAMES-eng": "2400", + "NUMBER_OF_BYTES-eng": "10000000" + } + } + ], + "format": { + "filename": "video_nanosecond_duration_bitrate.mkv", + "nb_streams": 1, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "100.000000", + "size": "10001000", + "probe_score": 100, + "tags": { + "encoder": "libebml v1.4.2 + libmatroska v1.6.4" + } + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 0b103debad..d94d56bc20 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -81,25 +81,25 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidPixel", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc - [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc [InlineData("RokuSSPlus", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // JellyfinMediaPlayer [InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -118,21 +118,21 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")] - [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "DirectStream", "http")] // webm requested, aac not supported + [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // webm requested, aac not supported [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // #6450 [InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux", "http")] // #6450 // TranscodeMedia [InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] + [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] [InlineData("TranscodeMedia", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] + [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] [InlineData("TranscodeMedia", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] + [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] [InlineData("TranscodeMedia", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] - [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")] + [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")] [InlineData("TranscodeMedia", "mkv-av1-vorbis-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")] - [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")] - [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")] + [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")] + [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")] [InlineData("TranscodeMedia", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")] // DirectMedia [InlineData("DirectMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] @@ -150,9 +150,9 @@ namespace Jellyfin.Model.Tests [InlineData("LowBandwidth", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] [InlineData("LowBandwidth", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] [InlineData("LowBandwidth", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Null [InlineData("Null", "mp4-h264-aac-vtt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)] [InlineData("Null", "mp4-h264-ac3-aac-srt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)] @@ -170,10 +170,10 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 + [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 [InlineData("AndroidTVExoPlayer", "mp4-hevc-aac-4000k-r180", PlayMethod.DirectPlay)] // #13712 // AndroidTV NoHevcRotation - [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712 + [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712 // Tizen 3 Stereo [InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] @@ -255,23 +255,23 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] - [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] + [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 should be DirectPlay [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc - [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // JellyfinMediaPlayer [InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -289,7 +289,7 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 + [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 // Tizen 3 Stereo [InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] @@ -336,7 +336,7 @@ namespace Jellyfin.Model.Tests // Yatse [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -675,5 +675,59 @@ namespace Jellyfin.Model.Tests 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)] + // External graphical subs embedded into MKV when transcoding + [InlineData("pgssub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + [InlineData("dvdsub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + // External subs remain external when transcoding to non-MKV containers + [InlineData("srt", true, PlayMethod.Transcode, "mp4", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)] + [InlineData("srt", true, PlayMethod.Transcode, "ts", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)] + // External subs remain external during DirectPlay even with MKV + [InlineData("srt", true, PlayMethod.DirectPlay, "mkv", null, SubtitleDeliveryMethod.External)] + // Internal subs still embedded into MKV when transcoding (existing behavior) + [InlineData("srt", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + [InlineData("pgssub", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] + public void GetSubtitleProfile_ReturnsExpectedDeliveryMethod( + string codec, + bool isExternal, + PlayMethod playMethod, + string outputContainer, + MediaStreamProtocol? transcodingSubProtocol, + SubtitleDeliveryMethod expectedMethod) + { + var mediaSource = new MediaSourceInfo(); + var subtitleStream = new MediaStream + { + Codec = codec, + Language = "eng", + IsExternal = isExternal, + Type = MediaStreamType.Subtitle, + SupportsExternalStream = true + }; + + var subtitleProfiles = new[] + { + new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.Embed }, + new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.External } + }; + + var transcoderSupport = new Mock<ITranscoderSupport>(); + transcoderSupport.Setup(x => x.CanExtractSubtitles(It.IsAny<string>())).Returns(true); + + var result = StreamBuilder.GetSubtitleProfile( + mediaSource, + subtitleStream, + subtitleProfiles, + playMethod, + transcoderSupport.Object, + outputContainer, + transcodingSubProtocol); + + Assert.Equal(expectedMethod, result.Method); + } } } diff --git a/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs new file mode 100644 index 0000000000..19ee13cd75 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs @@ -0,0 +1,58 @@ +using Emby.Naming.Book; +using Xunit; + +namespace Jellyfin.Naming.Tests.Book; + +public class BookResolverTests +{ + [Theory] + // seriesName (seriesYear?) #index (of count?) (year?) + [InlineData("Sherlock Holmes (1887) #1 (of 4) (1887)", null, "Sherlock Holmes", 1, 1887)] + [InlineData("Sherlock Holmes #2", null, "Sherlock Holmes", 2, null)] + [InlineData("Sherlock Holmes (1887) #1", null, "Sherlock Holmes", 1, null)] + [InlineData("Sherlock Holmes #2 (1890)", null, "Sherlock Holmes", 2, 1890)] + // name (seriesName, #index) (year?) + [InlineData("A Study in Scarlet (Sherlock Holmes, #1) (1887)", "A Study in Scarlet", "Sherlock Holmes", 1, 1887)] + [InlineData("The Adventures of Sherlock Holmes (Sherlock Holmes, #5)", "The Adventures of Sherlock Holmes", "Sherlock Holmes", 5, null)] + // name (year) + [InlineData("The Sign of the Four (1890)", "The Sign of the Four", null, null, 1890)] + [InlineData("The Valley of Fear (1915)", "The Valley of Fear", null, null, 1915)] + // index - name (year?) + [InlineData("2 - The Sign of the Four (1890)", "The Sign of the Four", null, 2, 1890)] + [InlineData("4 - The Valley of Fear", "The Valley of Fear", null, 4, null)] + // parse entire string as book name + [InlineData("A Study in Scarlet", "A Study in Scarlet", null, null, null)] + [InlineData("The Adventures of Sherlock Holmes", "The Adventures of Sherlock Holmes", null, null, null)] + // leading zeros on index number + [InlineData("00 - Dracula's Guest (1914)", "Dracula's Guest", null, 0, 1914)] + [InlineData("01 - Dracula (1897)", "Dracula", null, 1, 1897)] + // basic decimal support for prequels and novellas + [InlineData("2.0 - Twenty Thousand Leagues Under the Sea", "Twenty Thousand Leagues Under the Sea", null, 2, null)] + // TODO decide how to process non-zero decimals + [InlineData("2.1 - The Blockade Runners", "2.1 - The Blockade Runners", null, null, null)] + public void Resolve_Books(string input, string? name, string? series, int? index, int? year) + { + var result = BookFileNameParser.Parse(input); + + Assert.Equal(name, result.Name); + Assert.Equal(series, result.SeriesName); + Assert.Equal(index, result.Index); + Assert.Equal(year, result.Year); + } + + [Theory] + // name volume? chapter? (year?) + [InlineData("Captain Marvel Adventures v01 (1941)", "Captain Marvel Adventures v01", null, null, 1, 1941)] + [InlineData("Captain Marvel Adventures c120", "Captain Marvel Adventures c120", null, 120, null, null)] + [InlineData("Captain Marvel Adventures v01 c120", "Captain Marvel Adventures v01 c120", null, 120, 1, null)] + public void Resolve_Comics(string input, string? name, string? series, int? chapter, int? volume, int? year) + { + var result = BookFileNameParser.Parse(input); + + Assert.Equal(name, result.Name); + Assert.Equal(series, result.SeriesName); + Assert.Equal(chapter, result.Index); + Assert.Equal(volume, result.ParentIndex); + Assert.Equal(year, result.Year); + } +} diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs index 4dbe769bf4..2035140f00 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs @@ -83,4 +83,26 @@ public class SeasonPathParserTests Assert.Equal(seasonNumber, result.SeasonNumber); Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); } + + [Theory] + [InlineData("/Drive/300 Collection/300 (2006)", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/300 Rise of an Empire", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/1", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/300 Disc 1", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Days Later", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Weeks Later (2007)", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Years Later 2025", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/300 Collection/Season 1", "/Drive/300 Collection", 1, true)] + [InlineData("/Drive/28 Years Later Collection/Season 01", "/Drive/28 Years Later Collection", 1, true)] + [InlineData("/Drive/300 Collection/S01", "/Drive/300 Collection", 1, true)] + [InlineData("/Drive/300 Collection/S1", "/Drive/300 Collection", 1, true)] + + public void GetSeasonNumberFromPathMixedLibraryTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory) + { + var result = SeasonPathParser.Parse(path, parentPath, false, false); + + Assert.Equal(result.SeasonNumber is not null, result.Success); + Assert.Equal(seasonNumber, result.SeasonNumber); + Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); + } } diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs index 99604e0933..aaa500b762 100644 --- a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs @@ -1,7 +1,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; -using MediaBrowser.Providers.Plugins.ComicVine; +using MediaBrowser.Providers.Books.ComicVine; using Xunit; namespace Jellyfin.Providers.Tests.ExternalId diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs index eec64ac53f..b9ce895dbc 100644 --- a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs @@ -1,7 +1,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; -using MediaBrowser.Providers.Plugins.GoogleBooks; +using MediaBrowser.Providers.Books.GoogleBooks; using Xunit; namespace Jellyfin.Providers.Tests.ExternalId diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs index a7491f42e9..2438ef06d1 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs @@ -37,9 +37,9 @@ public class FFProbeVideoInfoTests { Assert.Throws<ArgumentException>( () => _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - })); + { + RunTimeTicks = runtime + })); } [Theory] @@ -53,9 +53,9 @@ public class FFProbeVideoInfoTests public void CreateDummyChapters_ValidRuntime_CorrectChaptersCount(long? runtime, int chaptersCount) { var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - }); + { + RunTimeTicks = runtime + }); Assert.Equal(chaptersCount, chapters.Length); } @@ -69,9 +69,9 @@ public class FFProbeVideoInfoTests public void CreateDummyChapters_PositiveRuntime_NoChapterBeyondRuntime(long runtime) { var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video() - { - RunTimeTicks = runtime - }); + { + RunTimeTicks = runtime + }); Assert.All(chapters, chapter => Assert.True(chapter.StartPositionTicks < runtime)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs new file mode 100644 index 0000000000..96625ae670 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs @@ -0,0 +1,137 @@ +using System; +using Emby.Server.Implementations.Dto; +using Emby.Server.Implementations.Playlists; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +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 Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceImageInheritanceTests +{ + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/playlists-custom.jpg", + DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.NotNull(dto.ParentPrimaryImageItemId); + Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag); + Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary)); + } + + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = [] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.Null(dto.ParentPrimaryImageItemId); + Assert.Null(dto.ParentPrimaryImageTag); + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]); + } + + private static DtoService BuildDtoService(BaseItem displayParent) + { + var libraryManager = new Mock<ILibraryManager>(); + var userDataManager = new Mock<IUserDataManager>(); + var imageProcessor = new Mock<IImageProcessor>(); + var providerManager = new Mock<IProviderManager>(); + var recordingsManager = new Mock<IRecordingsManager>(); + var appHost = new Mock<IApplicationHost>(); + var mediaSourceManager = new Mock<IMediaSourceManager>(); + var liveTvManager = new Mock<ILiveTvManager>(); + var trickplayManager = new Mock<ITrickplayManager>(); + 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); + + return new DtoService( + logger.Object, + libraryManager.Object, + userDataManager.Object, + imageProcessor.Object, + providerManager.Object, + recordingsManager.Object, + appHost.Object, + mediaSourceManager.Object, + new Lazy<ILiveTvManager>(() => liveTvManager.Object), + trickplayManager.Object, + chapterManager.Object); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs new file mode 100644 index 0000000000..9c247d54b9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -0,0 +1,138 @@ +using System; +using Emby.Server.Implementations.Dto; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceTests +{ + private readonly Mock<ILibraryManager> _libraryManagerMock; + private readonly DtoService _dtoService; + + public DtoServiceTests() + { + _libraryManagerMock = new Mock<ILibraryManager>(); + + var imageProcessor = new Mock<IImageProcessor>(); + // Deterministic tag derived from the image so each item gets a distinct, assertable tag. + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) + .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path); + + var appHost = new Mock<IApplicationHost>(); + appHost.Setup(x => x.SystemId).Returns("test-server"); + + // Video.SourceType probes the active-recording manager; provide one so it doesn't NRE. + Video.RecordingsManager = new Mock<IRecordingsManager>().Object; + + _dtoService = new DtoService( + NullLogger<DtoService>.Instance, + _libraryManagerMock.Object, + new Mock<IUserDataManager>().Object, + imageProcessor.Object, + new Mock<IProviderManager>().Object, + new Mock<IRecordingsManager>().Object, + appHost.Object, + new Mock<IMediaSourceManager>().Object, + new Lazy<ILiveTvManager>(() => new Mock<ILiveTvManager>().Object), + new Mock<ITrickplayManager>().Object, + new Mock<IChapterManager>().Object); + + // Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager. + BaseItem.LibraryManager = _libraryManagerMock.Object; + } + + [Fact] + public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage() + { + var (episode, season, _) = BuildEpisode(seasonHasPoster: true); + var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] }; + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio, + // and clients decide per view whether to prefer the parent/series poster over the episode still. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); + Assert.Equal(season.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); + // Aspect ratio stays the episode's own image, not the poster's. + Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio); + } + + [Fact] + public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster() + { + var (episode, _, series) = BuildEpisode(seasonHasPoster: false); + var options = new DtoOptions(false); + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // Episode image is retained; ParentPrimaryImage falls back to the series poster. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); + Assert.Equal(series.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); + } + + [Fact] + public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary() + { + var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false); + var options = new DtoOptions(false); + + var dto = _dtoService.GetBaseItemDto(episode, options); + + // With no season or series poster there is nothing to attach; the episode keeps its own primary. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Null(dto.ParentPrimaryImageItemId); + } + + 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 + // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode. + var series = new Series { Id = Guid.NewGuid(), Name = "Series" }; + if (seriesHasPoster) + { + series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0); + } + + var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id }; + if (seasonHasPoster) + { + season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0); + } + + var episode = new Episode + { + Id = Guid.NewGuid(), + Name = "Episode", + SeasonId = season.Id, + SeriesId = series.Id + }; + episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0); + + _libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season); + _libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series); + + return (episode, season, series); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs index c06279af2d..6cadfacce8 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs @@ -10,7 +10,7 @@ using Xunit; namespace Jellyfin.Server.Implementations.Tests.IO; -public class ManagedFileSystemTests +public partial class ManagedFileSystemTests { private readonly IFixture _fixture; private readonly ManagedFileSystem _sut; @@ -117,7 +117,7 @@ public class ManagedFileSystemTests } [SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")] - [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)] + [LibraryImport("libc", SetLastError = true)] [DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)] - private static extern int symlink(string target, string linkpath); + private static partial int symlink([MarshalAs(UnmanagedType.LPStr)] string target, [MarshalAs(UnmanagedType.LPStr)] string linkpath); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs new file mode 100644 index 0000000000..b7fca74310 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -0,0 +1,237 @@ +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes. + +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; + +/// <summary> +/// Verifies that the alternate-version-aware query shapes used by the resume filter +/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate +/// and evaluate correctly on the SQLite provider. +/// </summary> +public sealed class AlternateVersionQueryTranslationTests : IDisposable +{ + 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] + public void ResumeFilter_VersionProgress_SurfacesPlayedVersion() + { + Guid userId, primaryId, versionId, otherId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, versionId, otherId) = Seed(ctx); + } + + using (var ctx = CreateDbContext()) + { + var inProgress = ctx.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + // Scope to the seeded items; EnsureCreated also seeds a placeholder row. + var seededIds = new[] { primaryId, versionId, otherId }; + + // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery. + var inProgressIds = inProgress.Select(ud => ud.ItemId); + var resumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.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))) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([versionId], resumable); + + // The not-resumable direction keeps primaries only. + var resumableMovieIds = inProgress + .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); + var notResumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) + .Where(e => !resumableMovieIds.Contains(e.Id)) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([otherId], notResumable); + } + } + + [Fact] + public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion() + { + Guid userId, primaryId, versionAId, versionBId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx); + } + + using (var ctx = CreateDbContext()) + { + var inProgress = ctx.UserData + .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); + + var seededIds = new[] { primaryId, versionAId, versionBId }; + var inProgressIds = inProgress.Select(ud => ud.ItemId); + + // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the + // expression translates on SQLite and that two versions sharing an identical LastPlayedDate + // collapse to a single row instead of double-listing the item in Continue Watching. + var resumable = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id)) + .Where(e => inProgressIds.Contains(e.Id)) + .Where(e => !ctx.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))) + .Select(e => e.Id) + .ToList(); + + var survivor = Assert.Single(resumable); + Assert.Contains(survivor, new[] { versionAId, versionBId }); + } + } + + [Fact] + public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate() + { + Guid userId, primaryId, otherId; + + using (var ctx = CreateDbContext()) + { + (userId, primaryId, _, otherId) = Seed(ctx); + } + + using (var ctx = CreateDbContext()) + { + // Scope to the seeded items; EnsureCreated also seeds a placeholder row. + var seededIds = new[] { primaryId, otherId }; + + // Mirrors the DatePlayed mapping in OrderMapper. + var ordered = ctx.BaseItems + .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null) + .OrderByDescending(e => ctx.UserData + .Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)) + .Max(f => f.LastPlayedDate)) + .Select(e => e.Id) + .ToList(); + + // The movie whose only progress is on its alternate version sorts before the unplayed one. + Assert.Equal([primaryId, otherId], ordered); + } + } + + private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx) + { + var user = new User("test", "auth-provider", "reset-provider"); + ctx.Users.Add(user); + + var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + ctx.BaseItems.AddRange(primary, version, other); + + // Progress only on the alternate version. + ctx.UserData.Add(new UserData + { + ItemId = version.Id, + Item = version, + UserId = user.Id, + User = user, + CustomDataKey = version.Id.ToString("N"), + PlaybackPositionTicks = 1000, + LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) + }); + + ctx.SaveChanges(); + return (user.Id, primary.Id, version.Id, other.Id); + } + + private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx) + { + var user = new User("test", "auth-provider", "reset-provider"); + ctx.Users.Add(user); + + var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" }; + var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id }; + ctx.BaseItems.AddRange(primary, versionA, versionB); + + // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break. + var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc); + ctx.UserData.Add(new UserData + { + ItemId = versionA.Id, + Item = versionA, + UserId = user.Id, + User = user, + CustomDataKey = versionA.Id.ToString("N"), + PlaybackPositionTicks = 1000, + LastPlayedDate = tied + }); + ctx.UserData.Add(new UserData + { + ItemId = versionB.Id, + Item = versionB, + UserId = user.Id, + User = user, + CustomDataKey = versionB.Id.ToString("N"), + PlaybackPositionTicks = 2000, + LastPlayedDate = tied + }); + + 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/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs new file mode 100644 index 0000000000..083f725db9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs @@ -0,0 +1,144 @@ +using System; +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 +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly BaseItemRepository _repository; + private readonly string _movieTypeName; + + 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]; + + 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(); + } + + [Fact] + public void GetItemList_VersionGroup_ReturnsPrimaryVersion() + { + // The alternate version sorts before the primary by id, so a plain Min(Id) per + // presentation key would wrongly pick the alternate as the group representative. + var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + var alternateId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var presentationKey = primaryId.ToString("N"); + + using (var ctx = CreateDbContext()) + { + ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie", presentationKey, null)); + ctx.BaseItems.Add(CreateMovieEntity(alternateId, "Movie - 1080p", presentationKey, primaryId)); + ctx.SaveChanges(); + } + + var result = _repository.GetItemList(CreateQuery()); + + var item = Assert.Single(result); + Assert.Equal(primaryId, item.Id); + } + + [Fact] + public void GetItemList_GroupWithoutPrimary_FallsBackToMinId() + { + var firstId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); + var otherPrimaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); + var presentationKey = otherPrimaryId.ToString("N"); + + using (var ctx = CreateDbContext()) + { + ctx.BaseItems.Add(CreateMovieEntity(firstId, "Movie", presentationKey, otherPrimaryId)); + ctx.BaseItems.Add(CreateMovieEntity(secondId, "Movie - 4K", presentationKey, otherPrimaryId)); + ctx.SaveChanges(); + } + + var result = _repository.GetItemList(CreateQuery()); + + var item = Assert.Single(result); + Assert.Equal(firstId, item.Id); + } + + private static InternalItemsQuery CreateQuery() + { + // IncludeOwnedItems keeps the alternate version rows in the query so the + // grouping collapse is what picks the group representative. + return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset")) + { + IncludeItemTypes = [BaseItemKind.Movie], + IncludeOwnedItems = true + }; + } + + private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId) + { + return new BaseItemEntity + { + Id = id, + Type = _movieTypeName, + Name = name, + PresentationUniqueKey = presentationKey, + PrimaryVersionId = primaryVersionId, + 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/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 958ffb8b6e..29de52a2ba 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -4,6 +4,7 @@ <PropertyGroup> <ProjectGuid>{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}</ProjectGuid> <OutputType>Exe</OutputType> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> <ItemGroup> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index facdb2bc2e..c80f899498 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using AutoFixture; using AutoFixture.AutoMoq; using Castle.Components.DictionaryAdapter; @@ -7,6 +9,8 @@ using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -144,5 +148,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user); Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex); } + + [Fact] + public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + // The most recently played version is the default source, so resuming plays the right file. + // Per-user positions live in each version's UserData, not on the source. + Assert.Equal(alt2.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + SetupUserDataBatch(new Dictionary<Guid, UserItemData> + { + [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) } + }); + + var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user); + + // An explicitly opened version keeps its own source first, even when a sibling was + // played more recently. + Assert.Equal(alt1.Id.ToString("N"), sources[0].Id); + Assert.Equal(3, sources.Count); + } + + [Fact] + public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst() + { + var (primary, _, _) = SetupVersionGroup(); + SetupUserDataBatch([]); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + } + + [Fact] + public void GetStaticMediaSources_NoUser_DoesNotTouchUserData() + { + var (primary, _, _) = SetupVersionGroup(); + + var sources = _mediaSourceManager.GetStaticMediaSources(primary, false); + + Assert.Equal(primary.Id.ToString("N"), sources[0].Id); + _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never); + } + + private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData) + { + _mockUserDataManager + .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>())) + .Returns((IReadOnlyList<BaseItem> items, User _) => items + .Where(i => userData.ContainsKey(i.Id)) + .ToDictionary(i => i.Id, i => userData[i.Id])); + } + + private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup() + { + var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" }; + var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id }; + var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id }; + + // BaseItem.GetMediaSources runs against the static service locators. + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File); + mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>()); + mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>()); + + var segmentManager = new Mock<IMediaSegmentManager>(); + segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id }); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>()); + libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary); + libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1); + libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2); + + var recordingsManager = new Mock<IRecordingsManager>(); + recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null); + + BaseItem.MediaSegmentManager = segmentManager.Object; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + BaseItem.LibraryManager = libraryManager.Object; + Video.RecordingsManager = recordingsManager.Object; + + return (primary, alt1, alt2); + } } } 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/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs new file mode 100644 index 0000000000..bd14ca008d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -0,0 +1,209 @@ +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); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 3b8fe5ca60..bdb726f06d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -345,6 +345,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization } [Fact] + public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive, + // so the region casing has to be preserved or the file is not found and we fall back to en-US. + var translated = localizationManager.GetLocalizedString("Books", "he-IL"); + Assert.Equal("ספרים", translated); + } + + [Fact] public void GetServerLocalizedString_UsesServerCulture() { var localizationManager = Setup(new ServerConfiguration diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs new file mode 100644 index 0000000000..bd9e680cd9 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using Emby.Server.Implementations.SyncPlay; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SyncPlay; + +public class GroupTests +{ + public GroupTests() + { + var mockLogger = new Mock<ILogger<Emby.Server.Implementations.SyncPlay.Group>>(); + MockLoggerFactory = new Mock<ILoggerFactory>(); + MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(mockLogger.Object); + + MockUserManager = new Mock<IUserManager>(); + MockSessionManager = new Mock<ISessionManager>(); + MockLibraryManager = new Mock<ILibraryManager>(); + MockItem = new Mock<BaseItem>(); + MockItem.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true); + } + + private Mock<ILoggerFactory> MockLoggerFactory { get; } + + private Mock<IUserManager> MockUserManager { get; } + + private Mock<ISessionManager> MockSessionManager { get; } + + private Mock<ILibraryManager> MockLibraryManager { get; } + + private Mock<BaseItem> MockItem { get; } + + [Fact] + public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible() + { + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(MockItem.Object); + + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); + var itemId = Guid.NewGuid(); + var playlist = new List<Guid> { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var result = group.HasAccessToPlayQueue(user); + + Assert.True(result); + } + + [Fact] + public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem() + { + MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null); + + Assert.Null(MockLibraryManager.Object.GetItemById(Guid.NewGuid())); + + var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object); + var itemId = Guid.NewGuid(); + var playlist = new List<Guid> { itemId }; + group.PlayQueue.Reset(); + group.PlayQueue.SetPlaylist(playlist); + + Assert.Single(group.PlayQueue.GetPlaylist()); + Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId); + + var user = new User("test-user", "auth-provider", "pwdreset-provider"); + var result = group.HasAccessToPlayQueue(user); + + Assert.False(result); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 92e10c9f92..4a10b2f607 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); Assert.Null(ex); } + + [Theory] + [InlineData("../evil")] + [InlineData("..\\evil")] + [InlineData("../../escape_attempt")] + [InlineData("..")] + [InlineData(".")] + [InlineData("")] + [InlineData(" ")] + [InlineData("foo/bar")] + [InlineData("foo\\bar")] + [InlineData("/absolute")] + [InlineData("foo\0bar")] + public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name) + { + var packageInfo = new InstallationInfo() + { + Name = name, + SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip", + Checksum = "11b5b2f1a9ebc4f66d6ef19018543361" + }; + + await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs new file mode 100644 index 0000000000..8149938b4d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Users; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public class UserManagerLockHelperTests + { + [Fact] + public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + Assert.True(helper.ShouldLock()); + + var outerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + var innerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + innerHandle.Dispose(); + Assert.False(helper.ShouldLock()); + + outerHandle.Dispose(); + Assert.True(helper.ShouldLock()); + } + + [Fact] + public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); + var secondEntered = false; + + var firstTask = Task.Run( + async () => + { + using var firstHandle = await helper.LockAsync(key); + firstAcquired.SetResult(true); + await releaseFirst.Task; + }, + TestContext.Current.CancellationToken); + + await firstAcquired.Task; + + var secondTask = Task.Run( + async () => + { + using var secondHandle = await helper.LockAsync(key); + secondEntered = true; + }, + TestContext.Current.CancellationToken); + + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.False(secondEntered); + + releaseFirst.SetResult(true); + + await Task.WhenAll(firstTask, secondTask); + Assert.True(secondEntered); + } + + [Fact] + public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + helper.Dispose(); + + await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid())); + } + + [Fact] + public void Dispose_WhenCalledMultipleTimes_DoesNotThrow() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + + helper.Dispose(); + var ex = Record.Exception(() => helper.Dispose()); + + Assert.Null(ex); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs new file mode 100644 index 0000000000..596bf58fb1 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerNormalizedUsernameTests.cs @@ -0,0 +1,240 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +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 UserManagerNormalizedUsernameTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserManager _userManager; + + public UserManagerNormalizedUsernameTests() + { + _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)); + } + + // ----- GetUserByName tests ----- + + [Theory] + // German umlauts + [InlineData("münchen", "MÜNCHEN")] + // Spanish tilde-n + [InlineData("Ñoño", "ÑOÑO")] + // ASCII, invariant uppercase lookup + [InlineData("jellyfin", "JELLYFIN")] + // Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130) + [InlineData("Çelebi", "ÇELEBI")] + public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName( + string username, string normalizedLookup) + { + await _userManager.CreateUserAsync(username); + + var found = _userManager.GetUserByName(normalizedLookup); + + Assert.NotNull(found); + Assert.Equal(username, found.Username); + } + + [Theory] + // German umlaut, look up by both upper and lower case + [InlineData("münchen")] + // Spanish tilde-n + [InlineData("Ñoño")] + // lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ' + [InlineData("ali")] + // mixed ASCII + umlaut + [InlineData("testüser")] + public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username) + { + await _userManager.CreateUserAsync(username); + + var upperFound = _userManager.GetUserByName(username.ToUpperInvariant()); + var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant()); + var exactFound = _userManager.GetUserByName(username); + + Assert.NotNull(upperFound); + Assert.NotNull(lowerFound); + Assert.NotNull(exactFound); + } + + [Theory] + [InlineData("nonexistent")] + // No user with NormalizedUsername = "MÜNCHEN" has been created + [InlineData("MÜNCHEN")] + public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName) + { + var result = _userManager.GetUserByName(lookupName); + + Assert.Null(result); + } + + // ----- CreateUserAsync duplicate detection tests ----- + + [Theory] + // German umlaut, case-swapped duplicate + [InlineData("münchen", "MÜNCHEN")] + // Spanish tilde-n, lowercase duplicate + [InlineData("Ñoño", "ñoño")] + // ASCII, uppercase duplicate + [InlineData("alice", "ALICE")] + // Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant() + [InlineData("çelebi", "ÇELEBI")] + public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException( + string existingUsername, string duplicateUsername) + { + await _userManager.CreateUserAsync(existingUsername); + + await Assert.ThrowsAsync<ArgumentException>( + () => _userManager.CreateUserAsync(duplicateUsername)); + } + + [Theory] + // Different non-ASCII names that do not collide after normalization + [InlineData("münchen", "münchen2")] + [InlineData("ali", "ali2")] + // Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E) + [InlineData("noño", "nono")] + public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers( + string firstUsername, string secondUsername) + { + var first = await _userManager.CreateUserAsync(firstUsername); + var second = await _userManager.CreateUserAsync(secondUsername); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.NotEqual(first.Id, second.Id); + } + + // ----- RenameUser tests ----- + + [Theory] + // Rename to non-ASCII name + [InlineData("alice", "münchen")] + // Rename between similar non-ASCII and ASCII + [InlineData("müller", "mueller")] + // Contains 'i': invariant uppercase is always 'I', never Turkish 'İ' + [InlineData("ali", "ALI2")] + // Rename to Spanish tilde-n name + [InlineData("testuser", "Ñoño")] + public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant( + string originalName, string newName) + { + var user = await _userManager.CreateUserAsync(originalName); + + await _userManager.RenameUser(user.Id, originalName, newName); + + var renamed = _userManager.GetUserById(user.Id); + Assert.NotNull(renamed); + Assert.Equal(newName, renamed.Username); + Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername); + } + + [Theory] + // Same name different case: NormalizedUsername already taken + [InlineData("münchen", "MÜNCHEN")] + // Spanish, lowercase conflicts with existing uppercase-normalised entry + [InlineData("Ñoño", "ñoño")] + // ASCII, capitalised conflict + [InlineData("alice", "Alice")] + // Mixed ASCII + umlaut + [InlineData("testüser", "TESTÜSER")] + public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException( + string existingUsername, string conflictingNewName) + { + var targetUser = await _userManager.CreateUserAsync("renametarget"); + await _userManager.CreateUserAsync(existingUsername); + + await Assert.ThrowsAsync<ArgumentException>( + () => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName)); + } + + 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/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.Integration.Tests/Controllers/LibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs index edbb46b34c..b9b2862c65 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs @@ -23,6 +23,7 @@ public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFa [InlineData("Items/{0}/ThemeMedia")] [InlineData("Items/{0}/Ancestors")] [InlineData("Items/{0}/Download")] + [InlineData("Items/{0}/Collections")] [InlineData("Artists/{0}/Similar")] [InlineData("Items/{0}/Similar")] [InlineData("Albums/{0}/Similar")] diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs index 7ea56be731..4e01282dcf 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Json; @@ -164,5 +163,33 @@ namespace Jellyfin.Server.Integration.Tests.Controllers using var response = await UpdateUserPassword(client, _testUserId, createRequest); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } + + [Fact] + [Priority(2)] + public async Task UpdateUser_UsernameCaseDifference_Success() + { + var client = _factory.CreateClient(); + + client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); + + using var response = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var userDto = await response.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken); + Assert.NotNull(userDto); + + userDto.Name = userDto.Name.ToLowerInvariant(); + + using var response2 = await client.PostAsJsonAsync($"Users?userId={_testUserId}", userDto, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode); + + using var response3 = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var newUserDto = await response3.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken); + Assert.NotNull(newUserDto); + Assert.Equal(userDto.Name, newUserDto.Name); + + // Sanity check, make sure we're testing something + Assert.NotEqual(TestUsername, userDto.Name); + } } } diff --git a/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs new file mode 100644 index 0000000000..fa15b33af6 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Emby.Server.Implementations.Session; +using Jellyfin.Api.Models.SyncPlayDtos; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller.Net; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +public sealed class SyncPlayLostWebSocketTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public SyncPlayLostWebSocketTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task LostWebSocket_EndsSession_And_RemovesEmptySyncPlayGroup() + { + var cancellationToken = TestContext.Current.CancellationToken; + var client = _factory.CreateClient(); + var accessToken = await AuthHelper.CompleteStartupAsync(client); + client.DefaultRequestHeaders.AddAuthHeader(accessToken); + + var wsClient = _factory.Server.CreateWebSocketClient(); + wsClient.ConfigureRequest = request => + request.Headers.Authorization = AuthHelper.DummyAuthHeader + $", Token={accessToken}"; + + var webSocket = await wsClient.ConnectAsync( + new UriBuilder(_factory.Server.BaseAddress) + { + Scheme = "ws", + Path = "websocket" + }.Uri, + cancellationToken); + + _ = DrainAsync(webSocket, cancellationToken); + + var watched = await WaitForWatchedWebSocketsAsync(TimeSpan.FromSeconds(10), cancellationToken); + var connection = Assert.Single(watched); + + using var createResponse = await client.PostAsync( + "SyncPlay/New", + JsonContent.Create(new NewGroupRequestDto { GroupName = "ZombieGroupRepro" }, options: JsonDefaults.Options), + cancellationToken); + Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); + Assert.Equal(1, await WaitForGroupCountAsync(client, 1, TimeSpan.FromSeconds(10), cancellationToken)); + + connection.LastKeepAliveDate = DateTime.UtcNow - TimeSpan.FromSeconds(180); + + var groupCount = await WaitForGroupCountAsync(client, 0, TimeSpan.FromSeconds(45), cancellationToken); + Assert.True( + groupCount == 0, + $"SyncPlay group still listed {groupCount} group(s) after the WebSocket was lost: " + + "the keep-alive watchdog removed the socket from its watchlist without closing " + + "the session, leaving a zombie participant in the group (SessionWebSocketListener)."); + } + + private static async Task DrainAsync(WebSocket webSocket, CancellationToken cancellationToken) + { + var buffer = new byte[4096]; + try + { + while (webSocket.State == WebSocketState.Open) + { + await webSocket.ReceiveAsync(buffer, cancellationToken); + } + } + catch + { + // The server tears the connection down once the watchdog gives up on it. + } + } + + private async Task<IReadOnlyList<IWebSocketConnection>> WaitForWatchedWebSocketsAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + var listener = _factory.Services.GetRequiredService<IEnumerable<IWebSocketListener>>() + .OfType<SessionWebSocketListener>() + .Single(); + var watchlistField = typeof(SessionWebSocketListener) + .GetField("_webSockets", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(watchlistField); + var watchlist = (IEnumerable<IWebSocketConnection>)watchlistField.GetValue(listener)!; + + var stopwatch = Stopwatch.StartNew(); + while (true) + { + try + { + var snapshot = watchlist.ToArray(); + if (snapshot.Length > 0 || stopwatch.Elapsed >= timeout) + { + return snapshot; + } + } + catch (InvalidOperationException) + { + // The watchdog mutated the set during enumeration; retry. + } + + await Task.Delay(100, cancellationToken); + } + } + + private static async Task<int> WaitForGroupCountAsync(HttpClient client, int expected, TimeSpan timeout, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var count = -1; + while (stopwatch.Elapsed < timeout) + { + using var response = await client.GetAsync("SyncPlay/List", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + count = document.RootElement.GetArrayLength(); + if (count == expected) + { + return count; + } + + await Task.Delay(500, cancellationToken); + } + + return count; + } +} |
