diff options
648 files changed, 24146 insertions, 7070 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 9b44eff4c6..bc574d7054 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.7", + "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 65752af977..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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + 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 2a26bf15a4..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 }} @@ -35,7 +35,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@c31aa4ed4f12f147061186cf2a029f307b5c3636 # v5.5.9 + uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 9d3d99cb71..6a0d7caa2e 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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..9dec170d70 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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 c42962786d..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,10 +115,12 @@ - [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) - [ploughpuff](https://github.com/ploughpuff) + - [poytiis](https://github.com/poytiis) - [pR0Ps](https://github.com/pR0Ps) - [PrplHaz4](https://github.com/PrplHaz4) - [RazeLighter777](https://github.com/RazeLighter777) @@ -229,6 +232,7 @@ - [LiHRaM](https://github.com/LiHRaM) - [MSalman5230](https://github.com/MSalman5230) - [dwandw](https://github.com/dwandw) + - [Lampan-git](https://github.com/Lampan-git) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index e8901b4a1d..cb02433c06 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,28 +26,28 @@ <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.7" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" /> - <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.7" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" /> - <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" /> @@ -57,29 +57,29 @@ <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.7" /> + <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/AudioBook/AudioBookNameParserResult.cs b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs index 3f2d7b2b0b..de78e75a91 100644 --- a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs +++ b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1815 + namespace Emby.Naming.AudioBook { /// <summary> 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/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 97b52e42af..9f98970df5 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -36,7 +36,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Naming</PackageId> - <VersionPrefix>10.12.0</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 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 72adfb2d96..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)*|[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)*|[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.Naming/Video/CleanDateTimeResult.cs b/Emby.Naming/Video/CleanDateTimeResult.cs index c675a19d0f..e367f92213 100644 --- a/Emby.Naming/Video/CleanDateTimeResult.cs +++ b/Emby.Naming/Video/CleanDateTimeResult.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1815 + namespace Emby.Naming.Video { /// <summary> diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index 8847ee9bc9..028b639122 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -17,8 +17,8 @@ namespace Emby.Naming.Video { Name = name; - Files = Array.Empty<VideoFileInfo>(); - AlternateVersions = Array.Empty<VideoFileInfo>(); + Files = []; + AlternateVersions = []; } /// <summary> @@ -40,10 +40,10 @@ namespace Emby.Naming.Video public IReadOnlyList<VideoFileInfo> Files { get; set; } /// <summary> - /// Gets or sets the alternate versions. + /// Gets or sets the alternate versions. Each alternate may itself span multiple files. /// </summary> /// <value>The alternate versions.</value> - public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; } + public IReadOnlyList<VideoInfo> AlternateVersions { get; set; } /// <summary> /// Gets or sets the extra type. diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index a4bfb8d4a1..29330b132d 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -5,7 +5,8 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; -using Jellyfin.Extensions; +using Emby.Naming.TV; +using Jellyfin.Data.Enums; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -13,8 +14,23 @@ namespace Emby.Naming.Video /// <summary> /// Resolves alternative versions and extras from list of video files. /// </summary> - public static partial class VideoListResolver + public partial class VideoListResolver { + private static readonly StringComparer _numericOrdinalComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + + private readonly NamingOptions _namingOptions; + private readonly EpisodePathParser _episodePathParser; + + /// <summary> + /// Initializes a new instance of the <see cref="VideoListResolver"/> class. + /// </summary> + /// <param name="namingOptions">The naming options.</param> + public VideoListResolver(NamingOptions namingOptions) + { + _namingOptions = namingOptions; + _episodePathParser = new EpisodePathParser(namingOptions); + } + [GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)] private static partial Regex ResolutionRegex(); @@ -25,12 +41,12 @@ namespace Emby.Naming.Video /// Resolves alternative versions and extras from list of video files. /// </summary> /// <param name="videoInfos">List of related video files.</param> - /// <param name="namingOptions">The naming options.</param> /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param> /// <param name="parseName">Whether to parse the name or use the filename.</param> /// <param name="libraryRoot">Top-level folder for the containing library.</param> + /// <param name="collectionType">The type of the containing collection, if known.</param> /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> - public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "") + public IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "", CollectionType? collectionType = null) { // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer @@ -38,7 +54,7 @@ namespace Emby.Naming.Video .Where(i => i.ExtraType is null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList(); + var stackResult = StackResolver.Resolve(nonExtras, _namingOptions).ToList(); var remainingFiles = new List<VideoFileInfo>(); var standaloneMedia = new List<VideoFileInfo>(); @@ -67,7 +83,7 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot)) + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, _namingOptions, parseName, libraryRoot)) .OfType<VideoFileInfo>() .ToList() }; @@ -86,7 +102,9 @@ namespace Emby.Naming.Video if (supportMultiVersion) { - list = GetVideosGroupedByVersion(list, namingOptions); + list = collectionType is CollectionType.tvshows + ? GetEpisodesGroupedByVersion(list) + : GetVideosGroupedByVersion(list); } // Whatever files are left, just add them @@ -100,7 +118,7 @@ namespace Emby.Naming.Video return list; } - private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions) + private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos) { if (videos.Count == 0) { @@ -124,7 +142,7 @@ namespace Emby.Naming.Video continue; } - if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) + if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension)) { return videos; } @@ -135,45 +153,9 @@ namespace Emby.Naming.Video } } - if (videos.Count > 1) - { - var groups = videos - .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) - .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) - .GroupBy(x => x.resolutionMatch.Success) - .ToList(); - - videos.Clear(); + var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString()); - StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); - foreach (var group in groups) - { - if (group.Key) - { - videos.InsertRange(0, group - .OrderByDescending(x => x.resolutionMatch.Value, comparer) - .ThenBy(x => x.filename, comparer) - .Select(x => x.value)); - } - else - { - videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value)); - } - } - } - - primary ??= videos[0]; - videos.Remove(primary); - - var list = new List<VideoInfo> - { - primary - }; - - list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray(); - list[0].Name = folderName.ToString(); - - return list; + return [organized]; } private static bool HaveSameYear(IReadOnlyList<VideoInfo> videos) @@ -195,7 +177,7 @@ namespace Emby.Naming.Video return true; } - private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions) + private bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename) { if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { @@ -209,7 +191,7 @@ namespace Emby.Naming.Video } // There are no span overloads for regex unfortunately - if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName)) + if (CleanStringParser.TryClean(testFilename.ToString(), _namingOptions.CleanStringRegexes, out var cleanName)) { testFilename = cleanName.AsSpan().Trim(); } @@ -221,5 +203,113 @@ namespace Emby.Naming.Video || testFilename[0] == '.' || CheckMultiVersionRegex().IsMatch(testFilename); } + + private List<VideoInfo> GetEpisodesGroupedByVersion(List<VideoInfo> videos) + { + if (videos.Count < 2) + { + return videos; + } + + var result = new List<VideoInfo>(); + var groups = new Dictionary<string, List<VideoInfo>>(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < videos.Count; i++) + { + var video = videos[i]; + var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false); + string? key = null; + if (episodeResult.Success) + { + if (episodeResult.IsByDate + && episodeResult.Year.HasValue + && episodeResult.Month.HasValue + && episodeResult.Day.HasValue) + { + key = FormattableString.Invariant( + $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}"); + } + else if (episodeResult.EpisodeNumber.HasValue) + { + key = FormattableString.Invariant( + $"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}"); + } + } + + if (key is null) + { + result.Add(video); + continue; + } + + if (!groups.TryGetValue(key, out var group)) + { + group = []; + groups[key] = group; + } + + group.Add(video); + } + + foreach (var group in groups.Values) + { + if (group.Count == 1) + { + result.Add(group[0]); + continue; + } + + result.Add(OrganizeAlternateVersions(group)); + } + + return result; + } + + private static VideoInfo OrganizeAlternateVersions( + List<VideoInfo> videos, + VideoInfo? primaryOverride = null, + string? nameOverride = null) + { + if (videos.Count > 1) + { + var groups = videos + .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) + .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) + .GroupBy(x => x.resolutionMatch.Success) + .ToList(); + + videos = []; + + foreach (var group in groups) + { + if (group.Key) + { + videos.InsertRange(0, group + .OrderByDescending(x => x.resolutionMatch.Value, _numericOrdinalComparer) + .ThenBy(x => x.filename, _numericOrdinalComparer) + .Select(x => x.value)); + } + else + { + videos.AddRange(group.OrderBy(x => x.filename, _numericOrdinalComparer).Select(x => x.value)); + } + } + } + + // Prefer a stacked entry (more than one part) as primary + var primary = primaryOverride + ?? videos.FirstOrDefault(v => v.Files.Count > 1) + ?? videos[0]; + videos.Remove(primary); + + primary.AlternateVersions = videos; + + if (nameOverride is not null) + { + primary.Name = nameOverride; + } + + return primary; + } } } 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 b7aa2f3d06..69e23bcb63 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -14,6 +14,7 @@ using System.Reflection; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Emby.Naming.Common; +using Emby.Naming.Video; using Emby.Photos; using Emby.Server.Implementations.Chapters; using Emby.Server.Implementations.Collections; @@ -25,6 +26,8 @@ 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; using Emby.Server.Implementations.Plugins; @@ -90,9 +93,16 @@ 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; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api; using MediaBrowser.Providers.Plugins.Tmdb; +using MediaBrowser.Providers.Plugins.Tmdb.Movies; +using MediaBrowser.Providers.Plugins.Tmdb.TV; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; @@ -166,8 +176,6 @@ namespace Emby.Server.Implementations ConfigurationManager.Configuration, ApplicationPaths.PluginsPath, ApplicationVersion); - - _disposableParts.Add(_pluginManager); } /// <summary> @@ -485,6 +493,19 @@ namespace Emby.Server.Implementations serviceCollection.AddScoped<ISystemManager, SystemManager>(); serviceCollection.AddSingleton<TmdbClientManager>(); + serviceCollection.AddSingleton<TmdbMovieSimilarProvider>(); + serviceCollection.AddSingleton<TmdbSeriesSimilarProvider>(); + + 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); @@ -530,15 +551,20 @@ 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>(); serviceCollection.AddSingleton<IMusicManager, MusicManager>(); serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); serviceCollection.AddSingleton<DotIgnoreIgnoreRule>(); - serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); + serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>(); + + serviceCollection.AddSingleton<ISearchManager, SearchManager>(); + serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); @@ -695,6 +721,9 @@ namespace Emby.Server.Implementations GetExports<IExternalUrlProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); + + Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); + Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>()); } /// <summary> @@ -1014,6 +1043,8 @@ namespace Emby.Server.Implementations } _disposableParts.Clear(); + + _pluginManager?.Dispose(); } _disposed = true; 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 cc57d183b6..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 @@ -203,6 +209,39 @@ namespace Emby.Server.Implementations.Dto } } + // Batch-fetch MusicArtist lookups across all items to avoid N+1 queries. + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null; + var artistNames = new HashSet<string>(StringComparer.Ordinal); + foreach (var item in accessibleItems) + { + if (item is IHasArtist hasArtist) + { + foreach (var name in hasArtist.Artists) + { + if (!string.IsNullOrWhiteSpace(name)) + { + artistNames.Add(name); + } + } + } + + if (item is IHasAlbumArtist hasAlbumArtist) + { + foreach (var name in hasAlbumArtist.AlbumArtists) + { + if (!string.IsNullOrWhiteSpace(name)) + { + artistNames.Add(name); + } + } + } + } + + if (artistNames.Count > 0) + { + artistsBatch = _libraryManager.GetArtists(artistNames.ToArray()); + } + for (int index = 0; index < accessibleItems.Count; index++) { var item = accessibleItems[index]; @@ -214,7 +253,9 @@ namespace Emby.Server.Implementations.Dto userDataBatch?.GetValueOrDefault(item.Id), allCollectionFolders, childCountBatch, - playedCountBatch); + playedCountBatch, + artistsBatch, + resumeDataBatch?.GetValueOrDefault(item.Id)); if (item is LiveTvChannel tvChannel) { @@ -274,7 +315,9 @@ namespace Emby.Server.Implementations.Dto UserItemData? userData = null, List<Folder>? allCollectionFolders = null, Dictionary<Guid, int>? childCountBatch = null, - Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null) + Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null, + IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, + VersionResumeData? resumeData = null) { var dto = new BaseItemDto { @@ -318,7 +361,8 @@ namespace Emby.Server.Implementations.Dto options, userData, childCountBatch, - playedCountBatch); + playedCountBatch, + resumeData); } if (item is IHasMediaSources @@ -334,7 +378,7 @@ namespace Emby.Server.Implementations.Dto AttachStudios(dto, item); } - AttachBasicFields(dto, item, owner, options); + AttachBasicFields(dto, item, owner, options, artistsBatch, user); if (options.ContainsField(ItemFields.CanDelete)) { @@ -503,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) { @@ -565,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 { @@ -907,7 +955,9 @@ namespace Emby.Server.Implementations.Dto /// <param name="item">The item.</param> /// <param name="owner">The owner.</param> /// <param name="options">The options.</param> - private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options) + /// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param> + /// <param name="user">The user, for per-user values such as the accessible media source count.</param> + private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null) { if (options.ContainsField(ItemFields.DateCreated)) { @@ -1031,6 +1081,8 @@ namespace Emby.Server.Implementations.Dto dto.OriginalTitle = item.OriginalTitle; } + dto.OriginalLanguage = item.OriginalLanguage; + if (options.ContainsField(ItemFields.ParentId)) { dto.ParentId = item.DisplayParentId; @@ -1152,7 +1204,8 @@ namespace Emby.Server.Implementations.Dto // Include artists that are not in the database yet, e.g., just added via metadata editor // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); - var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]); + var artistsLookup = artistsBatch + ?? _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]); dto.ArtistItems = hasArtist.Artists .Where(name => !string.IsNullOrWhiteSpace(name)) @@ -1186,7 +1239,8 @@ namespace Emby.Server.Implementations.Dto // }) // .ToList(); - var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]); + var albumArtistsLookup = artistsBatch + ?? _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]); dto.AlbumArtists = hasAlbumArtist.AlbumArtists .Where(name => !string.IsNullOrWhiteSpace(name)) @@ -1217,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; @@ -1326,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; @@ -1464,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 373b0994a6..dc7f972c13 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Globalization; using System.IO.Pipelines; using System.Net; using System.Net.WebSockets; @@ -69,6 +70,11 @@ namespace Emby.Server.Implementations.HttpServer /// <inheritdoc /> public IPAddress? RemoteEndPoint { get; } + /// <summary> + /// Gets or initializes the UI culture captured from the upgrade request. + /// </summary> + public CultureInfo? RequestUICulture { get; init; } + /// <inheritdoc /> public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; } @@ -82,6 +88,17 @@ namespace Emby.Server.Implementations.HttpServer public WebSocketState State => _socket.State; /// <inheritdoc /> + public void ApplyRequestCulture() + { + if (RequestUICulture is null) + { + return; + } + + CultureInfo.CurrentUICulture = RequestUICulture; + } + + /// <inheritdoc /> public async Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) { var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); @@ -110,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/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index cb5b3993b8..072034c4bf 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.WebSockets; using System.Threading.Tasks; @@ -47,14 +48,18 @@ namespace Emby.Server.Implementations.HttpServer _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); - var connection = new WebSocketConnection( _loggerFactory.CreateLogger<WebSocketConnection>(), webSocket, authorizationInfo, context.GetNormalizedRemoteIP()) { - OnReceive = ProcessWebSocketMessageReceived + RequestUICulture = CultureInfo.CurrentUICulture + }; + connection.OnReceive = result => + { + connection.ApplyRequestCulture(); + return ProcessWebSocketMessageReceived(result); }; await using (connection.ConfigureAwait(false)) { 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..b701e7eb6d 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,38 +28,7 @@ namespace Emby.Server.Implementations.Images { var view = (CollectionFolder)item; var viewType = view.CollectionType; - - BaseItemKind[] includeItemTypes; - - switch (viewType) - { - 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; - } - + var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); var recursive = viewType != CollectionType.playlists; return view.GetItemList(new InternalItemsQuery @@ -67,12 +36,9 @@ namespace Emby.Server.Implementations.Images 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 11f1496086..3691f4e19d 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -13,6 +13,7 @@ using System.Threading.Tasks; using BitFaster.Caching.Lru; using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; @@ -87,6 +88,8 @@ namespace Emby.Server.Implementations.Library private readonly IPathManager _pathManager; 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. @@ -129,6 +132,8 @@ namespace Emby.Server.Implementations.Library /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> + /// <param name="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, @@ -151,7 +156,9 @@ namespace Emby.Server.Implementations.Library IDirectoryService directoryService, IPeopleRepository peopleRepository, IPathManager pathManager, - DotIgnoreIgnoreRule dotIgnoreIgnoreRule) + DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + IMediaStreamRepository mediaStreamRepository, + Lazy<IExternalDataManager> externalDataManagerFactory) { _appHost = appHost; _logger = loggerFactory.CreateLogger<LibraryManager>(); @@ -181,6 +188,9 @@ namespace Emby.Server.Implementations.Library _configurationManager.ConfigurationUpdated += ConfigurationUpdated; + _mediaStreamRepository = mediaStreamRepository; + _externalDataManagerFactory = externalDataManagerFactory; + RecordConfigurationValues(_configurationManager.Configuration); } @@ -390,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)]); } @@ -570,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) @@ -787,6 +810,42 @@ namespace Emby.Server.Implementations.Library CollectionType? collectionType = null) => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType); + private void SetAdditionalPartsFromStack(Video altVideo, string path) + { + if (altVideo.AdditionalParts is { Length: > 0 }) + { + return; + } + + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + IEnumerable<FileSystemMetadata> siblings; + try + { + siblings = _fileSystem.GetFiles(directory); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate siblings to detect stack for {Path}", path); + return; + } + + var stacks = StackResolver.Resolve(siblings, _namingOptions); + foreach (var stack in stacks) + { + if (stack.Files.Count > 1 + && string.Equals(stack.Files[0], path, StringComparison.OrdinalIgnoreCase)) + { + altVideo.AdditionalParts = stack.Files.Skip(1).ToArray(); + return; + } + } + } + /// <inheritdoc /> public Video? ResolveAlternateVersion(string path, Type expectedVideoType, Folder? parent, CollectionType? collectionType) { @@ -1945,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 { @@ -2307,6 +2367,10 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); allItems.Add(altVideo); } } @@ -2386,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; @@ -2510,6 +2580,10 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); allItems.Add(altVideo); } } @@ -3344,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(); @@ -3800,5 +3880,11 @@ namespace Emby.Server.Implementations.Library SetTopParentOrAncestorIds(query); return _itemRepository.GetQueryFiltersLegacy(query); } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); + } } } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index c667fb0600..97e00177b6 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -23,6 +23,8 @@ using MediaBrowser.Common.Configuration; 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; @@ -126,6 +128,11 @@ namespace Emby.Server.Implementations.Library return true; } + if (stream.IsVobSubSubtitleStream) + { + return true; + } + return false; } @@ -170,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 @@ -186,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); @@ -220,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 />> @@ -318,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; @@ -355,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); @@ -369,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; @@ -423,7 +517,7 @@ namespace Emby.Server.Implementations.Library MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage); } - private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection) + private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection, string originalLanguage) { if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection) { @@ -437,7 +531,38 @@ namespace Emby.Server.Implementations.Library } } - var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); + if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase)) + { + if (user.PlayDefaultAudioTrack) + { + source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex( + source.MediaStreams, + NormalizeLanguage(originalLanguage), + user.PlayDefaultAudioTrack); + return; + } + + var originalIndex = source.MediaStreams.FindIndex(i => i.Type == MediaStreamType.Audio && i.IsOriginal); + + if (!string.IsNullOrWhiteSpace(originalLanguage) && originalIndex != -1) + { + var mediaLanguageOriginal = source.MediaStreams[originalIndex].Language; + if (NormalizeLanguage(mediaLanguageOriginal).Contains(NormalizeLanguage(originalLanguage).FirstOrDefault())) + { + source.DefaultAudioStreamIndex = originalIndex; + return; + } + } + else if (originalIndex != -1) + { + source.DefaultAudioStreamIndex = originalIndex; + return; + } + } + + var preferredAudio = string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(originalLanguage) + ? NormalizeLanguage(originalLanguage) + : NormalizeLanguage(user.AudioLanguagePreference); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); if (user.PlayDefaultAudioTrack) @@ -462,7 +587,9 @@ namespace Emby.Server.Implementations.Library var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections; - SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection); + var originalLanguage = item?.GetInheritedOriginalLanguage(); + + SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection); } else if (mediaType == MediaType.Audio) @@ -476,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 fc63251ad0..7591359ea4 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Library '[' => ']', '(' => ')', '{' => '}', - _ => '\0' + _ => '\0' }; if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-')) { @@ -70,6 +70,16 @@ 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 a9b7a1274b..fad948ad97 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -6,6 +6,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library; @@ -14,18 +15,22 @@ namespace Emby.Server.Implementations.Library; /// </summary> public class PathManager : IPathManager { + private readonly ILogger<PathManager> _logger; private readonly IServerConfigurationManager _config; private readonly IApplicationPaths _appPaths; /// <summary> /// Initializes a new instance of the <see cref="PathManager"/> class. /// </summary> + /// <param name="logger">The logger.</param> /// <param name="config">The server configuration manager.</param> /// <param name="appPaths">The application paths.</param> public PathManager( + ILogger<PathManager> logger, IServerConfigurationManager config, IApplicationPaths appPaths) { + _logger = logger; _config = config; _appPaths = appPaths; } @@ -35,31 +40,43 @@ public class PathManager : IPathManager private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); /// <inheritdoc /> - public string GetAttachmentPath(string mediaSourceId, string fileName) + public string? GetAttachmentPath(string mediaSourceId, string fileName) { - return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName); + var folder = GetAttachmentFolderPath(mediaSourceId); + return folder is null ? null : Path.Combine(folder, fileName); } /// <inheritdoc /> - public string GetAttachmentFolderPath(string mediaSourceId) + public string? GetAttachmentFolderPath(string mediaSourceId) { - var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + if (!Guid.TryParse(mediaSourceId, out var parsed)) + { + _logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk attachment folder.", mediaSourceId); + return null; + } + var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan(); return Path.Join(AttachmentCachePath, id[..2], id); } /// <inheritdoc /> - public string GetSubtitleFolderPath(string mediaSourceId) + public string? GetSubtitleFolderPath(string mediaSourceId) { - var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan(); + if (!Guid.TryParse(mediaSourceId, out var parsed)) + { + _logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk subtitle folder.", mediaSourceId); + return null; + } + var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan(); return Path.Join(SubtitleCachePath, id[..2], id); } /// <inheritdoc /> - public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension) + public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension) { - return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension); + var folder = GetSubtitleFolderPath(mediaSourceId); + return folder is null ? null : Path.Combine(folder, streamIndex.ToString(CultureInfo.InvariantCulture) + extension); } /// <inheritdoc /> @@ -90,12 +107,27 @@ public class PathManager : IPathManager public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item) { var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture); - return [ - GetAttachmentFolderPath(mediaSourceId), - GetSubtitleFolderPath(mediaSourceId), - GetTrickplayDirectory(item, false), - GetTrickplayDirectory(item, true), - GetChapterImageFolderPath(item) - ]; + List<string> paths = []; + var attachmentFolder = GetAttachmentFolderPath(mediaSourceId); + if (attachmentFolder is not null) + { + paths.Add(attachmentFolder); + } + + var subtitleFolder = GetSubtitleFolderPath(mediaSourceId); + if (subtitleFolder is not null) + { + paths.Add(subtitleFolder); + } + + paths.Add(GetTrickplayDirectory(item, false)); + 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 98e8f5350b..68b66ab7f5 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -28,15 +28,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver { private readonly IImageProcessor _imageProcessor; + private readonly VideoListResolver _videoListResolver; - private static readonly CollectionType[] _validCollectionTypes = new[] - { + private static readonly CollectionType[] _validCollectionTypes = + [ CollectionType.movies, CollectionType.homevideos, CollectionType.musicvideos, CollectionType.tvshows, CollectionType.photos - }; + ]; /// <summary> /// Initializes a new instance of the <see cref="MovieResolver"/> class. @@ -45,10 +46,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> - public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) + /// <param name="videoListResolver">The video list resolver.</param> + public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService, VideoListResolver videoListResolver) : base(logger, namingOptions, directoryService) { _imageProcessor = imageProcessor; + _videoListResolver = videoListResolver; } /// <summary> @@ -228,7 +231,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (collectionType == CollectionType.tvshows) { - return ResolveVideos<Episode>(parent, files, false, collectionType, true); + return ResolveVideos<Episode>(parent, files, true, collectionType, true); } return null; @@ -274,7 +277,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies .Where(f => f is not null) .ToList(); - var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName, parent.ContainingFolderPath); + var resolverResult = _videoListResolver.Resolve(videoInfos, supportMultiEditions, parseName, parent.ContainingFolderPath, collectionType); var result = new MultiItemResolverResult { @@ -302,7 +305,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies ProductionYear = video.Year, Name = parseName ? video.Name : firstVideo.Name, AdditionalParts = additionalParts, - LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray() + LocalAlternateVersions = video.AlternateVersions.Select(av => av.Files[0].Path).ToArray() }; SetVideoType(videoItem, firstVideo); @@ -331,9 +334,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies for (var j = 0; j < current.AlternateVersions.Count; j++) { - if (ContainsFile(current.AlternateVersions[j], file)) + var alternate = current.AlternateVersions[j]; + for (var k = 0; k < alternate.Files.Count; k++) { - return true; + if (ContainsFile(alternate.Files[k], file)) + { + return true; + } } } } 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/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index 5fd23c9f50..85bf20cc2a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -1,8 +1,10 @@ #nullable disable using System; +using System.IO; using System.Linq; using Emby.Naming.Common; +using Emby.Server.Implementations.Library; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV episode.ParentIndexNumber = 1; } + SetProviderIdFromPath(episode, args.Path); + return episode; } return null; } + + /// <summary> + /// Sets provider ids from the episode file name. + /// </summary> + /// <param name="item">The episode.</param> + /// <param name="path">The episode file path.</param> + private static void SetProviderIdFromPath(Episode item, string path) + { + var justName = Path.GetFileNameWithoutExtension(path.AsSpan()); + + var imdbId = justName.GetAttributeValue("imdbid"); + item.TrySetProviderId(MetadataProvider.Imdb, imdbId); + + var tvdbId = justName.GetAttributeValue("tvdbid"); + item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId); + + var tvmazeId = justName.GetAttributeValue("tvmazeid"); + item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId); + + var tmdbId = justName.GetAttributeValue("tmdbid"); + item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 6cb63a28a2..6e9a38fd34 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -1,10 +1,15 @@ #nullable disable +using System; using System.Globalization; +using System.IO; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; @@ -77,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + + var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file))); + + if (!hasAnyVideo) + { + return null; + } } if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name)) @@ -91,10 +104,31 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV args.LibraryOptions.PreferredMetadataLanguage); } + SetProviderIdFromPath(season, path); + return season; } return null; } + + /// <summary> + /// Sets provider ids from the season folder name. + /// </summary> + /// <param name="item">The season.</param> + /// <param name="path">The season folder path.</param> + private static void SetProviderIdFromPath(Season item, string path) + { + var justName = Path.GetFileName(path.AsSpan()); + + var tvdbId = justName.GetAttributeValue("tvdbid"); + item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId); + + var tvmazeId = justName.GetAttributeValue("tvmazeid"); + item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId); + + var tmdbId = justName.GetAttributeValue("tmdbid"); + item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId); + } } } 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/AudioSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs new file mode 100644 index 0000000000..1cc670b8ee --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for audio tracks. +/// </summary> +public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider<Audio> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public AudioSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Audio item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.Audio], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs new file mode 100644 index 0000000000..7665ee2f79 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for Live TV programs. +/// </summary> +public class LiveTvProgramSimilarItemsProvider : ILocalSimilarItemsProvider<LiveTvProgram> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvProgramSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public LiveTvProgramSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(LiveTvProgram item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + BaseItemKind[] includeItemTypes; + bool enableGroupByMetadataKey; + bool enableTotalRecordCount; + + if (item.IsMovie) + { + // Movie-like program + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + includeItemTypes = [.. itemTypes]; + enableGroupByMetadataKey = true; + enableTotalRecordCount = false; + } + else if (item.IsSeries) + { + // Series-like program + includeItemTypes = [BaseItemKind.Series]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + else + { + // Default - match same type + includeItemTypes = [item.GetBaseItemKind()]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = includeItemTypes, + EnableGroupByMetadataKey = enableGroupByMetadataKey, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs new file mode 100644 index 0000000000..57d1f7c770 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -0,0 +1,342 @@ +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.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 using weighted scoring. +/// </summary> +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer>, IBatchLocalSimilarItemsProvider +{ + 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="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( + IDbContextFactory<JellyfinDbContext> dbProvider, + IItemQueryHelpers queryHelpers, + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager) + { + _dbProvider = dbProvider; + _queryHelpers = queryHelpers; + _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItemDto>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var results = await GetBatchSimilarItemsAsync([item], query, cancellationToken).ConfigureAwait(false); + return results.TryGetValue(item.Id, out var items) ? items : []; + } + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken) + => item switch + { + Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken), + Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken), + _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) + }; + + /// <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 limit = query.Limit ?? 50; + var dtoOptions = query.DtoOptions ?? new DtoOptions(); + + if (sourceItems.Count > MaxBatchSourceItems) + { + 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); + + // 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/MusicAlbumSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs new file mode 100644 index 0000000000..c13045deda --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music albums. +/// </summary> +public class MusicAlbumSimilarItemsProvider : ILocalSimilarItemsProvider<MusicAlbum> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicAlbumSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicAlbumSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicAlbum item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicAlbum], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs new file mode 100644 index 0000000000..3331419442 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music artists. +/// </summary> +public class MusicArtistSimilarItemsProvider : ILocalSimilarItemsProvider<MusicArtist> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicArtistSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicArtistSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicArtist item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicArtist], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs new file mode 100644 index 0000000000..0366fb752e --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for TV series. +/// </summary> +public class SeriesSimilarItemsProvider : ILocalSimilarItemsProvider<Series> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SeriesSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public SeriesSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Series item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [BaseItemKind.Series], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs new file mode 100644 index 0000000000..d923cff07e --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -0,0 +1,638 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +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; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Manages similar items providers and orchestrates similar items operations. +/// </summary> +public class SimilarItemsManager : ISimilarItemsManager +{ + private readonly ILogger<SimilarItemsManager> _logger; + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + private ISimilarItemsProvider[] _similarItemsProviders = []; + + /// <summary> + /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <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, + IServerConfigurationManager serverConfigurationManager) + { + _logger = logger; + _appPaths = appPaths; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public void AddParts(IEnumerable<ISimilarItemsProvider> providers) + { + _similarItemsProviders = providers.ToArray(); + } + + /// <inheritdoc/> + public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem + { + var itemType = typeof(T); + return _similarItemsProviders + .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType)) + || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType))) + .ToList(); + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(excludeArtistIds); + + var itemType = item.GetType(); + var requestedLimit = limit ?? 50; + var itemKind = item.GetBaseItemKind(); + + // Ensure ProviderIds is included in DtoOptions for matching remote provider responses + if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds)) + { + dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray(); + } + + // Local providers are always enabled. Remote providers must be explicitly enabled. + var localProviders = _similarItemsProviders + .OfType<ILocalSimilarItemsProvider>() + .Where(p => p.Supports(itemType)) + .ToList(); + var remoteProviders = _similarItemsProviders + .OfType<IRemoteSimilarItemsProvider>() + .Where(p => p.Supports(itemType)); + var matchingProviders = new List<ISimilarItemsProvider>(localProviders); + + var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name); + if (typeOptions?.SimilarItemProviders?.Length > 0) + { + matchingProviders.AddRange(remoteProviders + .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase))); + } + + var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order + ? order + : typeOptions?.SimilarItemProviders; + var orderedProviders = matchingProviders + .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name)) + .ToList(); + + 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) + { + break; + } + + try + { + if (provider is ILocalSimilarItemsProvider localProvider) + { + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false); + + foreach (var (position, resultItem) in items.Index()) + { + 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)); + } + } + } + else if (provider is IRemoteSimilarItemsProvider remoteProvider) + { + var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id); + + var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); + if (cachedReferences is not null) + { + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); + allResults.AddRange(resolvedItems); + continue; + } + + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + // Collect references in batches and resolve against local library. + // Stop fetching once we have enough resolved local items. + const int BatchSize = 20; + var remaining = requestedLimit - allResults.Count; + var collectedReferences = new List<SimilarItemReference>(); + var pendingBatch = new List<SimilarItemReference>(); + + await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false)) + { + collectedReferences.Add(reference); + pendingBatch.Add(reference); + + if (pendingBatch.Count >= BatchSize) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); + allResults.AddRange(resolvedItems); + remaining -= resolvedItems.Count; + pendingBatch.Clear(); + + if (remaining <= 0) + { + break; + } + } + } + + // Resolve any remaining references in the last partial batch + if (pendingBatch.Count > 0) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds, excludeKeys); + allResults.AddRange(resolvedItems); + } + + if (collectedReferences.Count > 0 && provider.CacheDuration is not null) + { + await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, item.Id); + } + } + + return allResults + .OrderByDescending(x => x.Score) + .Select(x => x.Item) + .Take(requestedLimit) + .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<string> excludeKeys) + { + if (references.Count == 0) + { + return []; + } + + 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()) + { + var lookupKey = (match.ProviderName, match.ProviderId); + if (!providerLookup.TryGetValue(lookupKey, out var existing)) + { + providerLookup[lookupKey] = (match.Score, position); + } + else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position)) + { + providerLookup[lookupKey] = (match.Score, position); + } + } + + var allProviderIds = providerLookup + .GroupBy(kvp => kvp.Key.ProviderName) + .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray()); + + var query = new InternalItemsQuery(user) + { + HasAnyProviderIds = allProviderIds, + IncludeItemTypes = [itemKind], + DtoOptions = dtoOptions + }; + + var items = _libraryManager.GetItemList(query); + + foreach (var item in items) + { + if (excludeIds.Contains(item.Id)) + { + continue; + } + + var presentationKey = item.GetPresentationUniqueKey(); + if (excludeKeys.Contains(presentationKey)) + { + continue; + } + + foreach (var providerName in allProviderIds.Keys) + { + if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) + { + var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); + if (!resolvedByKey.TryGetValue(presentationKey, out var existing) || existing.Score < score) + { + resolvedByKey[presentationKey] = (item, score); + } + + break; + } + } + } + + 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) + { + // Use provider-supplied score if available, otherwise derive from position + var baseScore = matchScore ?? (1.0f - (position * 0.02f)); + + // Apply small boost based on provider order (higher priority providers get small bonus) + var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f; + + return Math.Clamp(baseScore + priorityBoost, 0f, 1f); + } + + private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName) + { + if (orderConfig is null || orderConfig.Length == 0) + { + return int.MaxValue; + } + + var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : int.MaxValue; + } + + private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId) + { + var dataPath = Path.Combine( + _appPaths.CachePath, + $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}"); + return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json"); + } + + private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken cancellationToken) + { + var fileInfo = _fileSystem.GetFileSystemInfo(cachePath); + if (!fileInfo.Exists || fileInfo.Length == 0) + { + return null; + } + + try + { + var stream = File.OpenRead(cachePath); + await using (stream.ConfigureAwait(false)) + { + var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt) + { + return cache.References; + } + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath); + } + + return null; + } + + private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(cachePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var cache = new SimilarItemsCache + { + References = references, + ExpiresAt = DateTime.UtcNow.Add(cacheDuration) + }; + + var stream = File.Create(cachePath); + await using (stream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); + } + } + + private sealed class SimilarItemsCache + { + public List<SimilarItemReference>? References { get; set; } + + public DateTime ExpiresAt { get; set; } + } + + private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)> + { + public static readonly StringTupleComparer Instance = new(); + + public bool Equals((string Key, string Value) x, (string Key, string Value) y) + => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string Key, string Value) obj) + => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value)); + } +} diff --git a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs index 71ce3b6012..7c605036cf 100644 --- a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs +++ b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs @@ -80,7 +80,7 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask ImageTypes = [imageType], Limit = 30, // TODO max parental rating configurable - MaxParentalRating = new(10, null), + MaxParentalRating = new(13, null), OrderBy = [ (ItemSortBy.Random, SortOrder.Ascending) diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 1281f1587f..40cd2bb69c 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -247,6 +247,103 @@ namespace Emby.Server.Implementations.Library 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 + && localProbeIds.Contains(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); + + 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]); + } + } + + return result; + } + /// <summary> /// Gets the internal key. /// </summary> @@ -281,6 +378,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 +486,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/ab.json b/Emby.Server.Implementations/Localization/Core/ab.json index d6d257c5ba..d67f2d67e9 100644 --- a/Emby.Server.Implementations/Localization/Core/ab.json +++ b/Emby.Server.Implementations/Localization/Core/ab.json @@ -1,5 +1,3 @@ { - "Albums": "аальбомқәа", - "AppDeviceValues": "Апп: {0}, Априбор: {1}", - "Application": "Апрограмма" + "AppDeviceValues": "Апп: {0}, Априбор: {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json index 80c1bd0940..308341ad49 100644 --- a/Emby.Server.Implementations/Localization/Core/af.json +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -1,11 +1,8 @@ { "Artists": "Kunstenare", - "Channels": "Kanale", "Folders": "Lêergidse", "Favorites": "Gunstelinge", "HeaderFavoriteShows": "Gunsteling Vertonings", - "ValueSpecialEpisodeName": "Spesiale - {0}", - "HeaderAlbumArtists": "Album kunstenaars", "Books": "Boeke", "HeaderNextUp": "Volgende", "Movies": "Flieks", @@ -13,24 +10,13 @@ "HeaderContinueWatching": "Hou aan kyk", "HeaderFavoriteEpisodes": "Gunsteling Episodes", "Photos": "Foto's", - "Playlists": "Snitlyste", - "HeaderFavoriteArtists": "Gunsteling Kunstenaars", - "HeaderFavoriteAlbums": "Gunsteling Albums", - "Sync": "Sinkroniseer", - "HeaderFavoriteSongs": "Gunsteling Liedjies", - "Songs": "Liedjies", - "DeviceOnlineWithName": "{0} is aanlyn", - "DeviceOfflineWithName": "{0} is ontkoppel", "Collections": "Versamelings", "Inherit": "Ontvang", "HeaderLiveTV": "Lewendige TV", - "Application": "Program", "AppDeviceValues": "App: {0}, Toestel: {1}", "VersionNumber": "Weergawe {0}", - "ValueHasBeenAddedToLibrary": "{0} is by jou media biblioteek bygevoeg", "UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel", "UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel", - "UserPolicyUpdatedWithName": "Gebruiker beleid is verander vir {0}", "UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander", "UserOnlineFromDevice": "{0} is aanlyn van {1}", "UserOfflineFromDevice": "{0} is ontkoppel van {1}", @@ -38,19 +24,13 @@ "UserDownloadingItemWithValues": "{0} is besig om {1} af te laai", "UserDeletedWithName": "Gebruiker {0} is verwyder", "UserCreatedWithName": "Gebruiker {0} is geskep", - "User": "Gebruiker", "TvShows": "TV Programme", - "System": "Stelsel", "SubtitleDownloadFailureFromForItem": "Ondertitels het misluk om af te laai van {0} vir {1}", "StartupEmbyServerIsLoading": "Jellyfin Bediener is besig om te laai. Probeer weer in 'n kort tyd.", - "ServerNameNeedsToBeRestarted": "{0} moet herbegin word", - "ScheduledTaskStartedWithName": "{0} het begin", "ScheduledTaskFailedWithName": "{0} het misluk", - "ProviderValue": "Voorsiener: {0}", "PluginUpdatedWithName": "{0} was opgedateer", "PluginUninstalledWithName": "{0} was verwyder", "PluginInstalledWithName": "{0} is geïnstalleer", - "Plugin": "Inprop module", "NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop", "NotificationOptionVideoPlayback": "Video terugspeel het begin", "NotificationOptionUserLockedOut": "Gebruiker uitgeslyt", @@ -74,23 +54,14 @@ "MusicVideos": "Musiek Videos", "Music": "Musiek", "MixedContent": "Gemengde inhoud", - "MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer", - "MessageNamedServerConfigurationUpdatedWithValue": "Bediener konfigurasie seksie {0} is opgedateer", - "MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}", - "MessageApplicationUpdated": "Jellyfin Bediener is opgedateer", "Latest": "Nuutste", "LabelRunningTimeValue": "Werktyd: {0}", "LabelIpAddressValue": "IP adres: {0}", - "ItemRemovedWithName": "{0} is uit versameling verwyder", - "ItemAddedWithName": "{0} is by die versameling gevoeg", "HomeVideos": "Tuis Videos", - "HeaderRecordingGroups": "Groep Opnames", "Genres": "Genres", "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "ChapterNameValue": "Hoofstuk {0}", - "CameraImageUploadedFrom": "'n Nuwe kamera foto is opgelaai vanaf {0}", "AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer", - "Albums": "Albums", "TasksChannelsCategory": "Internet kanale", "TasksApplicationCategory": "aansoek", "TasksLibraryCategory": "biblioteek", diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index b80737d3b9..17af935562 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -1,41 +1,24 @@ { - "Albums": "الألبومات", "AppDeviceValues": "التطبيق: {0}، الجهاز: {1}", - "Application": "التطبيق", "Artists": "الفنانون", "AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح", "Books": "الكتب", - "CameraImageUploadedFrom": "تم رفع صورة كاميرا جديدة من {0}", - "Channels": "القنوات", "ChapterNameValue": "الفصل {0}", "Collections": "المجموعات", - "DeviceOfflineWithName": "انقطع اتصال {0}", - "DeviceOnlineWithName": "{0} متصل", "FailedLoginAttemptWithUserName": "محاولة تسجيل دخول فاشلة من {0}", "Favorites": "المفضلة", "Folders": "المجلدات", "Genres": "الأنواع", - "HeaderAlbumArtists": "فنانو الألبوم", "HeaderContinueWatching": "متابعة المشاهدة", - "HeaderFavoriteAlbums": "الألبومات المفضلة", - "HeaderFavoriteArtists": "الفنانون المفضلون", "HeaderFavoriteEpisodes": "الحلقات المفضلة", "HeaderFavoriteShows": "المسلسلات المفضلة", - "HeaderFavoriteSongs": "الأغاني المفضلة", "HeaderLiveTV": "البث التلفزيوني المباشر", "HeaderNextUp": "التالي", - "HeaderRecordingGroups": "مجموعات التسجيل", "HomeVideos": "فيديوهات منزلية", "Inherit": "وراثة", - "ItemAddedWithName": "تمت إضافة {0} إلى المكتبة", - "ItemRemovedWithName": "تمت إزالة {0} من المكتبة", "LabelIpAddressValue": "عنوان IP: {0}", "LabelRunningTimeValue": "مدة التشغيل: {0}", "Latest": "الأحدث", - "MessageApplicationUpdated": "تم تحديث خادم Jellyfin", - "MessageApplicationUpdatedTo": "تم تحديث خادم Jellyfin إلى {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "تم تحديث قسم إعدادات الخادم {0}", - "MessageServerConfigurationUpdated": "تم تحديث إعدادات الخادم", "MixedContent": "محتوى مختلط", "Movies": "الأفلام", "Music": "الموسيقى", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "بدأ تشغيل الفيديو", "NotificationOptionVideoPlaybackStopped": "توقف تشغيل الفيديو", "Photos": "الصور", - "Playlists": "قوائم التشغيل", - "Plugin": "الملحق", "PluginInstalledWithName": "تم تثبيت {0}", "PluginUninstalledWithName": "تمت إزالة {0}", "PluginUpdatedWithName": "تم تحديث {0}", - "ProviderValue": "المزوّد: {0}", "ScheduledTaskFailedWithName": "فشلت {0}", - "ScheduledTaskStartedWithName": "بدأت {0}", - "ServerNameNeedsToBeRestarted": "يحتاج {0} إلى إعادة التشغيل", "Shows": "المسلسلات", - "Songs": "الأغاني", "StartupEmbyServerIsLoading": "يتم الآن تحميل خادم Jellyfin. يرجى المحاولة مرة أخرى بعد قليل.", "SubtitleDownloadFailureFromForItem": "فشل تنزيل الترجمات من {0} لـ {1}", - "Sync": "مزامنة", - "System": "النظام", "TvShows": "البرامج التلفزيونية", - "User": "المستخدم", "UserCreatedWithName": "تم إنشاء المستخدم {0}", "UserDeletedWithName": "تم حذف المستخدم {0}", "UserDownloadingItemWithValues": "{0} يقوم بتنزيل {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "انقطع اتصال {0} من {1}", "UserOnlineFromDevice": "{0} متصل من {1}", "UserPasswordChangedWithName": "تم تغيير كلمة المرور للمستخدم {0}", - "UserPolicyUpdatedWithName": "تم تحديث سياسة المستخدم لـ {0}", "UserStartedPlayingItemWithValues": "{0} يقوم بتشغيل {1} على {2}", "UserStoppedPlayingItemWithValues": "أنهى {0} تشغيل {1} على {2}", - "ValueHasBeenAddedToLibrary": "تمت إضافة {0} إلى مكتبة المحتوى الخاصة بك", - "ValueSpecialEpisodeName": "خاص - {0}", "VersionNumber": "الإصدار {0}", "TaskCleanCacheDescription": "يحذف ملفات ذاكرة التخزين المؤقت التي لم يعد النظام بحاجة إليها.", "TaskCleanCache": "تنظيف مجلد ذاكرة التخزين المؤقت", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل", "TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.", "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم", - "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل." + "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.", + "Original": "فريد", + "LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/as.json b/Emby.Server.Implementations/Localization/Core/as.json index 7c7dd26e92..bc0c2c5ff5 100644 --- a/Emby.Server.Implementations/Localization/Core/as.json +++ b/Emby.Server.Implementations/Localization/Core/as.json @@ -1,18 +1,13 @@ { - "Albums": "এলবাম", - "Application": "আবেদন", "AppDeviceValues": "এপ্: {0}, ডিভাইচ: {1}", "Artists": "শিল্পী", - "Channels": "চেনেলস", "Default": "ডিফল্ট", "AuthenticationSucceededWithUserName": "{0} সফলভাবে প্রমাণিত", "Books": "পুস্তক", "Movies": "চলচ্চিত্ৰ", - "CameraImageUploadedFrom": "একটি নতুন ক্যামেরা চিত্র আপলোড করা হয়েছে {0}", "Collections": "সংগ্রহ", "HeaderFavoriteShows": "প্রিয় শোসমূহ", "Latest": "শেহতীয়া", - "MessageApplicationUpdated": "জেলিফিন চাইভাৰ আপডেট কৰা হৈছে", "MixedContent": "মিশ্ৰিত সমগ্ৰতা", "NewVersionIsAvailable": "ডাউনলোড কৰিবলৈ জেলিফিন চাইভাৰৰ এটা নতুন সংস্কৰণ উপলব্ধ আছে.", "NotificationOptionCameraImageUploaded": "কেমেৰাৰ চিত্ৰ আপল'ড কৰা হ'ল", @@ -21,20 +16,14 @@ "Folders": "ফোল্ডাৰ", "Forced": "বলপূর্বক", "Genres": "শ্রেণী", - "HeaderAlbumArtists": "অ্যালবাম শিল্পী", "HeaderContinueWatching": "দেখা চালিয়ে যান", "FailedLoginAttemptWithUserName": "লগইন ব্যর্থ চেষ্টা কৰা হৈছে থেকে {0}", - "HeaderFavoriteAlbums": "প্রিয় অ্যালবামসমূহ", - "HeaderFavoriteArtists": "প্রিয় শিল্পীসমূহ", "HeaderFavoriteEpisodes": "প্রিয় পর্বসমূহ", - "HeaderFavoriteSongs": "প্ৰিয় গীত", "HeaderLiveTV": "প্ৰতিবেদন টিভি", "HeaderNextUp": "পৰৱৰ্তী অংশ", - "HeaderRecordingGroups": "অলংকৰণ গোষ্ঠীসমূহ", "HearingImpaired": "শ্ৰবণ অক্ষম", "HomeVideos": "ঘৰৰ ভিডিঅ'সমূহ", "Inherit": "উত্তপ্ত কৰা", - "MessageServerConfigurationUpdated": "চাইভাৰ কনফিগাৰেশ্যন আপডেট কৰা হৈছে", "NotificationOptionApplicationUpdateAvailable": "অ্যাপ্লিকেশ্যন আপডেট উপলব্ধ", "NotificationOptionApplicationUpdateInstalled": "অ্যাপ্লিকেশ্যন আপডেট ইনষ্টল কৰা হ'ল", "NotificationOptionAudioPlayback": "অডিঅ' প্লেবেক আৰম্ভ হ'ল", 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/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 543d227e73..5d0ef65842 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -1,17 +1,10 @@ { - "Sync": "Сінхранізаваць", - "Playlists": "Плэй-лісты", "Latest": "Апошняе", "LabelIpAddressValue": "IP-адрас: {0}", - "ItemAddedWithName": "{0} дададзены ў бібліятэку", - "MessageApplicationUpdated": "Сервер Jellyfin абноўлены", "NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана", "PluginInstalledWithName": "{0} быў усталяваны", "UserCreatedWithName": "Карыстальнік {0} быў створаны", - "Albums": "Альбомы", - "Application": "Праграма", "AuthenticationSucceededWithUserName": "{0} паспяхова аўтарызаваны", - "Channels": "Каналы", "ChapterNameValue": "Раздзел {0}", "Collections": "Калекцыі", "Default": "Прадвызначана", @@ -21,16 +14,11 @@ "External": "Знешні", "Genres": "Жанры", "HeaderContinueWatching": "Працягнуць прагляд", - "HeaderFavoriteAlbums": "Абраныя альбомы", "HeaderFavoriteEpisodes": "Абраныя серыі", "HeaderFavoriteShows": "Абраныя шоу", - "HeaderFavoriteSongs": "Абраныя песні", "HeaderLiveTV": "Прамы эфір", - "HeaderAlbumArtists": "Выканаўцы альбома", "LabelRunningTimeValue": "Працягласць: {0}", "HomeVideos": "Хатнія відэа", - "ItemRemovedWithName": "{0} выдалены з бібліятэкі", - "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да версіі {0}", "Movies": "Фільмы", "Music": "Музыка", "MusicVideos": "Музычныя кліпы", @@ -41,19 +29,13 @@ "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна ўсталявана", "NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера", "Photos": "Фотаздымкі", - "Plugin": "Плагін", "PluginUninstalledWithName": "{0} быў выдалены", "PluginUpdatedWithName": "{0} быў абноўлены", - "ProviderValue": "Пастаўшчык: {0}", - "Songs": "Песні", - "System": "Сістэма", - "User": "Карыстальнік", "UserDeletedWithName": "Карыстальнік {0} быў выдалены", "UserDownloadingItemWithValues": "{0} спампоўваецца {1}", "TaskOptimizeDatabase": "Аптымізацыя базы даных", "Artists": "Выканаўцы", "UserOfflineFromDevice": "{0} адлучыўся ад {1}", - "UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}", "TaskCleanActivityLogDescription": "Выдаляе запісы старэйшыя за зададзены ўзрост ў журнале актыўнасці.", "TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.", "TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.", @@ -65,17 +47,10 @@ "TasksApplicationCategory": "Праграма", "AppDeviceValues": "Праграма: {0}, Прылада: {1}", "Books": "Кнігі", - "CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}", - "DeviceOfflineWithName": "{0} адлучыўся", - "DeviceOnlineWithName": "{0} падлучаны", "Forced": "Прымусова", - "HeaderRecordingGroups": "Групы запісаў", "HeaderNextUp": "Наступнае", - "HeaderFavoriteArtists": "Абраныя выканаўцы", "HearingImpaired": "Са слабым слыхам", "Inherit": "Атрымаць у спадчыну", - "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера (секцыя {0}) абноўлена", - "MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена", "MixedContent": "Змешаны змест", "NameSeasonUnknown": "Невядомы сезон", "NotificationOptionInstallationFailed": "Збой усталёўкі", @@ -91,8 +66,6 @@ "NotificationOptionVideoPlayback": "Пачалося прайграванне відэа", "NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена", "ScheduledTaskFailedWithName": "{0} не атрымалася", - "ScheduledTaskStartedWithName": "{0} пачалося", - "ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску", "Shows": "Шоу", "StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.", "SubtitleDownloadFailureFromForItem": "Субцітры для {1} не ўдалося спампаваць з {0}", @@ -103,8 +76,6 @@ "UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}", "UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}", "UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}", - "ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку", - "ValueSpecialEpisodeName": "Спецвыпуск - {0}", "VersionNumber": "Версія {0}", "TasksMaintenanceCategory": "Абслугоўванне", "TasksLibraryCategory": "Бібліятэка", diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 7340180241..0710a39708 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -1,41 +1,24 @@ { - "Albums": "Албуми", "AppDeviceValues": "Програма: {0}, Устройство: {1}", - "Application": "Програма", "Artists": "Артисти", "AuthenticationSucceededWithUserName": "{0} се удостовери успешно", "Books": "Книги", - "CameraImageUploadedFrom": "Нова снимка от камера беше качена от {0}", - "Channels": "Канали", "ChapterNameValue": "Глава {0}", "Collections": "Колекции", - "DeviceOfflineWithName": "{0} се разкачи", - "DeviceOnlineWithName": "{0} е свързан", "FailedLoginAttemptWithUserName": "Неуспешен опит за влизане от {0}", "Favorites": "Любими", "Folders": "Папки", "Genres": "Жанрове", - "HeaderAlbumArtists": "Изпълнители на албума", "HeaderContinueWatching": "Продължаване на гледането", - "HeaderFavoriteAlbums": "Любими албуми", - "HeaderFavoriteArtists": "Любими изпълнители", "HeaderFavoriteEpisodes": "Любими епизоди", "HeaderFavoriteShows": "Любими сериали", - "HeaderFavoriteSongs": "Любими песни", "HeaderLiveTV": "Телевизия на живо", "HeaderNextUp": "Следва", - "HeaderRecordingGroups": "Запис групи", "HomeVideos": "Домашни Клипове", "Inherit": "Наследяване", - "ItemAddedWithName": "{0} е добавено към библиотеката", - "ItemRemovedWithName": "{0} е премахнато от библиотеката", "LabelIpAddressValue": "IP адрес: {0}", "LabelRunningTimeValue": "Продължителност: {0}", "Latest": "Последни", - "MessageApplicationUpdated": "Сървърът беше обновен", - "MessageApplicationUpdatedTo": "Сървърът беше обновен до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация беше актуализирана", - "MessageServerConfigurationUpdated": "Конфигурацията на сървъра беше актуализирана", "MixedContent": "Смесено съдържание", "Movies": "Филми", "Music": "Музика", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Възпроизвеждането на видео започна", "NotificationOptionVideoPlaybackStopped": "Възпроизвеждането на видео е спряно", "Photos": "Снимки", - "Playlists": "Списъци", - "Plugin": "Добавка", "PluginInstalledWithName": "{0} е инсталиранa", "PluginUninstalledWithName": "{0} е деинсталиранa", "PluginUpdatedWithName": "{0} е обновенa", - "ProviderValue": "Доставчик: {0}", "ScheduledTaskFailedWithName": "{0} се провали", - "ScheduledTaskStartedWithName": "{0} започна", - "ServerNameNeedsToBeRestarted": "{0} трябва да се рестартира", "Shows": "Сериали", - "Songs": "Песни", "StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.", "SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени", - "Sync": "Синхронизиране", - "System": "Система", "TvShows": "Телевизионни сериали", - "User": "Потребител", "UserCreatedWithName": "Потребителят {0} е създаден", "UserDeletedWithName": "Потребителят {0} е изтрит", "UserDownloadingItemWithValues": "{0} изтегля {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} се разкачи от {1}", "UserOnlineFromDevice": "{0} е на линия от {1}", "UserPasswordChangedWithName": "Паролата на потребителя {0} е променена", - "UserPolicyUpdatedWithName": "Потребителската политика за {0} се актуализира", "UserStartedPlayingItemWithValues": "{0} пусна {1}", "UserStoppedPlayingItemWithValues": "{0} спря {1}", - "ValueHasBeenAddedToLibrary": "{0} беше добавен във Вашата библиотека", - "ValueSpecialEpisodeName": "Специални - {0}", "VersionNumber": "Версия {0}", "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи субтитри, на база конфигурацията за мета-данни.", "TaskDownloadMissingSubtitles": "Изтегляне на липсващи субтитри", diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index c6cfbe3c67..eb7fdf2b68 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -1,31 +1,19 @@ { - "DeviceOnlineWithName": "{0}-এর সাথে সংযুক্ত হয়েছে", - "DeviceOfflineWithName": "{0}-এর সাথে সংযোগ বিচ্ছিন্ন হয়েছে", "Collections": "সংগ্রহশালা", "ChapterNameValue": "অধ্যায় {0}", - "Channels": "চ্যানেলসমূহ", - "CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে", "Books": "পুস্তকসমূহ", "AuthenticationSucceededWithUserName": "{0} সফলভাবে অথেন্টিকেট করেছেন", "Artists": "শিল্পীগণ", - "Application": "অ্যাপ্লিকেশন", - "Albums": "অ্যালবামসমূহ", "HeaderFavoriteEpisodes": "প্রিয় পর্বগুলো", - "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", - "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", "HeaderContinueWatching": "দেখতে থাকুন", - "HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ", "Genres": "ধরণ", "Folders": "ফোল্ডারসমূহ", "Favorites": "পছন্দসমূহ", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {1}", "VersionNumber": "সংস্করণ {0}", - "ValueSpecialEpisodeName": "বিশেষ পর্ব - {0}", - "ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে", "UserStoppedPlayingItemWithValues": "{2}তে {1} প্লে শেষ করেছেন {0}", "UserStartedPlayingItemWithValues": "{2}তে {1} প্লে করেছেন {0}", - "UserPolicyUpdatedWithName": "{0} এর জন্য ব্যবহার নীতি আপডেট করা হয়েছে", "UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে", "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন আছে", "UserOfflineFromDevice": "{0} {1} থেকে বিচ্ছিন্ন হয়ে গেছে", @@ -33,23 +21,14 @@ "UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে", "UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে", "UserCreatedWithName": "ব্যবহারকারী {0} সৃষ্টি করা হয়েছে", - "User": "ব্যবহারকারী", "TvShows": "টিভি শোগুলো", - "System": "সিস্টেম", - "Sync": "সমন্বয় করুন", "SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে", "StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।", - "Songs": "সঙ্গীত সমূহ", "Shows": "শো সমূহ", - "ServerNameNeedsToBeRestarted": "{0} রিস্টার্ট করা প্রয়োজন", - "ScheduledTaskStartedWithName": "{0} শুরু হয়েছে", "ScheduledTaskFailedWithName": "{0} ব্যর্থ", - "ProviderValue": "প্রদানকারী: {0}", "PluginUpdatedWithName": "{0} আপডেট করা হয়েছে", "PluginUninstalledWithName": "{0} আনইন্সটল হয়েছে", "PluginInstalledWithName": "{0} ইন্সটল হয়েছে", - "Plugin": "প্লাগিন", - "Playlists": "প্লে লিস্ট সমূহ", "Photos": "ছবিসমূহ", "NotificationOptionVideoPlaybackStopped": "ভিডিও প্লেব্যাক বন্ধ হয়েছে", "NotificationOptionVideoPlayback": "ভিডিও প্লেব্যাক শুরু হয়েছে", @@ -75,21 +54,13 @@ "Music": "গান", "Movies": "চলচ্চিত্রসমূহ", "MixedContent": "মিশ্র কন্টেন্ট", - "MessageServerConfigurationUpdated": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে", - "HeaderRecordingGroups": "রেকর্ডিং গ্রুপগুলো", - "MessageNamedServerConfigurationUpdatedWithValue": "সার্ভার কনফিগারেশন সেকশন {0} আপডেট করা হয়েছে", - "MessageApplicationUpdatedTo": "জেলিফিন সার্ভার {0} তে আপডেট করা হয়েছে", - "MessageApplicationUpdated": "জেলিফিন সার্ভার আপডেট করা হয়েছে", "Latest": "সর্বশেষ", "LabelRunningTimeValue": "চলার সময়: {0}", "LabelIpAddressValue": "আইপি এড্রেস: {0}", - "ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে", - "ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে", "Inherit": "উত্তরাধিকারসূত্র থেকে গ্রহণ করুন", "HomeVideos": "হোম ভিডিও", "HeaderNextUp": "এরপরে আসছে", "HeaderLiveTV": "লাইভ টিভি", - "HeaderFavoriteSongs": "প্রিয় গানগুলো", "HeaderFavoriteShows": "প্রিয় শোগুলো", "TasksLibraryCategory": "লাইব্রেরি", "TasksMaintenanceCategory": "রক্ষণাবেক্ষণ", 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/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json index 3bf5ebd35a..aa7fe4eb24 100644 --- a/Emby.Server.Implementations/Localization/Core/bs.json +++ b/Emby.Server.Implementations/Localization/Core/bs.json @@ -1,52 +1,32 @@ { - "Albums": "Albumi", "Artists": "Umjetnici", "Books": "Knjige", - "Channels": "Kanalima", "Collections": "Zbirke", "Default": "Zadano", "Favorites": "Omiljeni", "Folders": "Mape", "Genres": "Žanrovi", - "HeaderAlbumArtists": "Umjetnici albuma", "HeaderContinueWatching": "Nastavi gledati", "Movies": "Filmovi", "MusicVideos": "Muzički spotovi", "Photos": "Slike", - "Playlists": "Plejliste", "Shows": "Pokazuje", - "Songs": "Pjesme", - "ValueSpecialEpisodeName": "Posebno - {0}", "AppDeviceValues": "Aplikacija: {0}, Uređaj: {1}", - "Application": "Prijava", "AuthenticationSucceededWithUserName": "{0} uspješno autentificirano", - "CameraImageUploadedFrom": "Nova slika s kamere je postavljena sa {0}", "ChapterNameValue": "Poglavlje {0}", - "DeviceOfflineWithName": "{0} se odspojio", - "DeviceOnlineWithName": "{0} je povezan", "External": "Vanjsko", "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave sa {0}", "Forced": "Prisilno", - "HeaderFavoriteAlbums": "Omiljeni albumi", - "HeaderFavoriteArtists": "Omiljeni umjetnici", "HeaderFavoriteEpisodes": "Omiljene epizode", "HeaderFavoriteShows": "Omiljene emisije", - "HeaderFavoriteSongs": "Omiljene pjesme", "HeaderLiveTV": "TV uživo", "HeaderNextUp": "Slijedi", - "HeaderRecordingGroups": "Grupe za snimanje", "HearingImpaired": "Oštećen sluh", "HomeVideos": "Kućni videozapisi", "Inherit": "Nasljedi", - "ItemAddedWithName": "{0} je dodan u biblioteku", - "ItemRemovedWithName": "{0} je uklonjen iz biblioteke", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Trajanje: {0}", "Latest": "Posljednje dodano", - "MessageApplicationUpdated": "Jellyfin Server je ažuriran", - "MessageApplicationUpdatedTo": "Jellyfin Server je ažuriran na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sekcija za konfiguraciju servera {0} je ažurirana", - "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana", "MixedContent": "Miješani sadržaj", "Music": "Muzika", "NameInstallFailed": "{0} instalacija je propala", @@ -69,21 +49,14 @@ "NotificationOptionUserLockedOut": "Korisnik je zaključan", "NotificationOptionVideoPlayback": "Pokrenuto je reproduciranje videa", "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa je zaustavljena", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} je instaliran", "PluginUninstalledWithName": "{0} je deinstaliran", "PluginUpdatedWithName": "{0} je ažurirano", - "ProviderValue": "Pružatelj: {0}", "ScheduledTaskFailedWithName": "{0} nije uspjelo", - "ScheduledTaskStartedWithName": "{0} počelo", - "ServerNameNeedsToBeRestarted": "{0} treba ponovo pokrenuti", "StartupEmbyServerIsLoading": "Jellyfin Server se učitava. Molimo pokušajte ponovo za kratko vrijeme.", "SubtitleDownloadFailureFromForItem": "Podtitlovi nisu uspjeli preuzeti sa {0} za {1}", - "Sync": "Sinkronizacija", - "System": "Sistem", "TvShows": "TV serije", "Undefined": "Nedefinirano", - "User": "Korisnik", "UserCreatedWithName": "Korisnik {0} je kreiran", "UserDeletedWithName": "Korisnik {0} je izbrisan", "UserDownloadingItemWithValues": "{0} preuzima {1}", @@ -91,10 +64,8 @@ "UserOfflineFromDevice": "{0} se odspojio od {1}", "UserOnlineFromDevice": "{0} je online od {1}", "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}", - "UserPolicyUpdatedWithName": "Pravila za korisnike su ažurirana za {0}", "UserStartedPlayingItemWithValues": "{0} igra protiv {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je završio igru protiv {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} je dodan u vašu medijsku biblioteku", "VersionNumber": "Verzija {0}", "TasksMaintenanceCategory": "Održavanje", "TasksLibraryCategory": "Biblioteka", diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index f9543e6f4c..6c81726ee6 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -1,41 +1,24 @@ { - "Albums": "Àlbums", "AppDeviceValues": "Aplicació: {0}, Dispositiu: {1}", - "Application": "Aplicació", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "Books": "Llibres", - "CameraImageUploadedFrom": "S'ha pujat una nova imatge de càmera des de {0}", - "Channels": "Canals", "ChapterNameValue": "Capítol {0}", "Collections": "Col·leccions", - "DeviceOfflineWithName": "{0} s'ha desconnectat", - "DeviceOnlineWithName": "{0} està connectat", "FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}", "Favorites": "Preferits", "Folders": "Directoris", "Genres": "Gèneres", - "HeaderAlbumArtists": "Artistes de l'àlbum", "HeaderContinueWatching": "Continueu mirant", - "HeaderFavoriteAlbums": "Àlbums preferits", - "HeaderFavoriteArtists": "Artistes preferits", "HeaderFavoriteEpisodes": "Episodis preferits", "HeaderFavoriteShows": "Sèries preferides", - "HeaderFavoriteSongs": "Cançons preferides", "HeaderLiveTV": "TV en directe", "HeaderNextUp": "A continuació", - "HeaderRecordingGroups": "Grups musicals", "HomeVideos": "Vídeos domèstics", "Inherit": "Heretat", - "ItemAddedWithName": "{0} s'ha afegit a la mediateca", - "ItemRemovedWithName": "{0} s'ha eliminat de la mediateca", "LabelIpAddressValue": "Adreça IP: {0}", "LabelRunningTimeValue": "Temps en marxa: {0}", "Latest": "Darrers", - "MessageApplicationUpdated": "El servidor de Jellyfin ha estat actualitzat", - "MessageApplicationUpdatedTo": "El servidor de Jellyfin ha estat actualitzat a {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La secció {0} de la configuració del servidor ha estat actualitzada", - "MessageServerConfigurationUpdated": "S'ha actualitzat la configuració del servidor", "MixedContent": "Contingut barrejat", "Movies": "Pel·lícules", "Music": "Música", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Reproducció de vídeo iniciada", "NotificationOptionVideoPlaybackStopped": "Reproducció de vídeo aturada", "Photos": "Fotos", - "Playlists": "Llistes de reproducció", - "Plugin": "Complement", "PluginInstalledWithName": "S'ha instal·lat {0}", "PluginUninstalledWithName": "S'ha desinstal·lat {0}", "PluginUpdatedWithName": "S'ha actualitzat {0}", - "ProviderValue": "Proveïdor: {0}", "ScheduledTaskFailedWithName": "{0} ha fallat", - "ScheduledTaskStartedWithName": "S'ha iniciat {0}", - "ServerNameNeedsToBeRestarted": "S'ha de reiniciar {0}", "Shows": "Sèries", - "Songs": "Cançons", "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho de nou en una estona.", "SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}", - "Sync": "Sincronitza", - "System": "Sistema", "TvShows": "Sèries de TV", - "User": "Usuari", "UserCreatedWithName": "S'ha creat l'usuari {0}", "UserDeletedWithName": "S'ha eliminat l'usuari {0}", "UserDownloadingItemWithValues": "{0} està descarregant {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} s'ha desconnectat de {1}", "UserOnlineFromDevice": "{0} està connectat des de {1}", "UserPasswordChangedWithName": "S'ha canviat la contrasenya per a l'usuari {0}", - "UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}", "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}", "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1} a {2}", - "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la mediateca", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versió {0}", "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.", "TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImages": "Migració de la ubicació de la imatge de previsualització", "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" + "CleanupUserDataTask": "Tasca de neteja de dades d'usuari", + "Original": "Original", + "LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/chr.json b/Emby.Server.Implementations/Localization/Core/chr.json index 85d1f4c881..7e039bafc2 100644 --- a/Emby.Server.Implementations/Localization/Core/chr.json +++ b/Emby.Server.Implementations/Localization/Core/chr.json @@ -1,44 +1,30 @@ { "ChapterNameValue": "Didanedi {0}", - "HeaderAlbumArtists": "Didanidanolisgisgi", - "HeaderFavoriteAlbums": "Dvganidi didanidisgisgi", "HeaderLiveTV": "Anigadi didanidisgosgi", - "HeaderRecordingGroups": "Didanisquodiisgisgi", "HomeVideos": "Diganadi dinagadisgisgi", "Inherit": "Anigwe", - "MessageApplicationUpdatedTo": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe anigadi {0}", "MixedContent": "Ganinidi dininoladisgisgi", "Movies": "Anidvnisgisgi", "MusicVideos": "Danodisgisgi didanidisgosgi", "NotificationOptionAudioPlayback": "Didanidigwe diganuyisgisgi anigadi", "NotificationOptionInstallationFailed": "Diudvdi anadvnatisgisgi", "NotificationOptionPluginUninstalled": "Ditsigvhnidv anawvdisgisgi", - "Albums": "Anigawidaniyv", - "Application": "Didanvyi", "Artists": "Dinidaniyi", "AuthenticationSucceededWithUserName": "{0} Sesoquonisdi nagadani", "Books": "Didanedi", - "CameraImageUploadedFrom": "Anigawidaniyv nasgi didagwalanvyi {0}", - "Channels": "Diganadasgi", "Collections": "Diganadisgi", "Default": "Dinadi", - "DeviceOfflineWithName": "{0} Aniyvolehvi nasgi", "External": "Amohdi", "Favorites": "Nvdayelvdisgi", "Folders": "Didanididisgi", "Forced": "Ganedi", "Genres": "Diganadisgi", "HeaderContinueWatching": "Uwoditsu asdanidisgisgi", - "HeaderFavoriteArtists": "Dvganidi dinidanolisgisgi", "HeaderFavoriteEpisodes": "Dvganidi didanidilisgadisgisgi", "HeaderFavoriteShows": "Dvganidi didanididanolisgisgi)", - "HeaderFavoriteSongs": "Dvganidi danodisgisgi", "HeaderNextUp": "Anidvli uwodoli", "HearingImpaired": "Anitsunidi talunidisgisgi", - "ItemAddedWithName": "{0} Dinigwe anididanidisgi", "Latest": "Uwodoli", - "MessageApplicationUpdated": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe", - "MessageServerConfigurationUpdated": "Sedanidvdi anigadi diganidinonvhi", "Music": "Danodisgisgi", "NameSeasonUnknown": "Tsunita anidvdisgi", "NewVersionIsAvailable": "Danodigwe anigadi Jellyfin Server tsadanidigwe adisdi uwodvdi diganidinonvhi.", diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 8d43839110..28f0e2df97 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -1,41 +1,24 @@ { - "Albums": "Alba", "AppDeviceValues": "Aplikace: {0}, Zařízení: {1}", - "Application": "Aplikace", "Artists": "Umělci", "AuthenticationSucceededWithUserName": "{0} úspěšně ověřen", "Books": "Knihy", - "CameraImageUploadedFrom": "Z {0} byla nahrána nová fotografie z fotoaparátu", - "Channels": "Kanály", "ChapterNameValue": "Kapitola {0}", "Collections": "Kolekce", - "DeviceOfflineWithName": "{0} se odpojil", - "DeviceOnlineWithName": "{0} je připojen", "FailedLoginAttemptWithUserName": "Neúspěšný pokus o přihlášení z {0}", "Favorites": "Oblíbené", "Folders": "Složky", "Genres": "Žánry", - "HeaderAlbumArtists": "Umělci alba", "HeaderContinueWatching": "Pokračovat ve sledování", - "HeaderFavoriteAlbums": "Oblíbená alba", - "HeaderFavoriteArtists": "Oblíbení interpreti", "HeaderFavoriteEpisodes": "Oblíbené epizody", "HeaderFavoriteShows": "Oblíbené seriály", - "HeaderFavoriteSongs": "Oblíbená hudba", "HeaderLiveTV": "TV vysílání", "HeaderNextUp": "Další díly", - "HeaderRecordingGroups": "Skupiny nahrávek", "HomeVideos": "Domácí videa", "Inherit": "Zdědit", - "ItemAddedWithName": "{0} byl přidán do knihovny", - "ItemRemovedWithName": "{0} byl odstraněn z knihovny", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Délka média: {0}", "Latest": "Nejnovější", - "MessageApplicationUpdated": "Jellyfin Server byl aktualizován", - "MessageApplicationUpdatedTo": "Jellyfin server byl aktualizován na verzi {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurace sekce {0} na serveru byla aktualizována", - "MessageServerConfigurationUpdated": "Konfigurace serveru aktualizována", "MixedContent": "Smíšený obsah", "Movies": "Filmy", "Music": "Hudba", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Přehrávání videa zahájeno", "NotificationOptionVideoPlaybackStopped": "Přehrávání videa ukončeno", "Photos": "Fotky", - "Playlists": "Seznamy skladeb", - "Plugin": "Zásuvný modul", "PluginInstalledWithName": "{0} byl nainstalován", "PluginUninstalledWithName": "{0} byl odinstalován", "PluginUpdatedWithName": "{0} byl aktualizován", - "ProviderValue": "Poskytl: {0}", "ScheduledTaskFailedWithName": "{0} selhalo", - "ScheduledTaskStartedWithName": "{0} zahájeno", - "ServerNameNeedsToBeRestarted": "{0} vyžaduje restart", "Shows": "Seriály", - "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server je spouštěn. Zkuste to prosím v brzké době znovu.", "SubtitleDownloadFailureFromForItem": "Stažení titulků pro {1} z {0} selhalo", - "Sync": "Synchronizace", - "System": "Systém", "TvShows": "Seriály", - "User": "Uživatel", "UserCreatedWithName": "Uživatel {0} byl vytvořen", "UserDeletedWithName": "Uživatel {0} byl smazán", "UserDownloadingItemWithValues": "{0} stahuje {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} se odpojil ze zařízení {1}", "UserOnlineFromDevice": "{0} se připojil ze zařízení {1}", "UserPasswordChangedWithName": "Provedena změna hesla pro uživatele {0}", - "UserPolicyUpdatedWithName": "Zásady uživatele pro {0} byly aktualizovány", "UserStartedPlayingItemWithValues": "{0} spustil přehrávání {1}", "UserStoppedPlayingItemWithValues": "{0} zastavil přehrávání {1}", - "ValueHasBeenAddedToLibrary": "{0} byl přidán do vaší knihovny médií", - "ValueSpecialEpisodeName": "Speciál - {0}", "VersionNumber": "Verze {0}", "TaskDownloadMissingSubtitlesDescription": "Vyhledá na internetu chybějící titulky na základě nastavení metadat.", "TaskDownloadMissingSubtitles": "Stáhnout chybějící titulky", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay", "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" + "CleanupUserDataTask": "Pročistit uživatelská data", + "Original": "Originál", + "LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json index af0e89bf80..e4d8ee7731 100644 --- a/Emby.Server.Implementations/Localization/Core/cy.json +++ b/Emby.Server.Implementations/Localization/Core/cy.json @@ -1,16 +1,11 @@ { - "DeviceOnlineWithName": "Mae {0} wedi'i gysylltu", - "DeviceOfflineWithName": "Mae {0} wedi datgysylltu", "Default": "Diofyn", "Collections": "Casgliadau", "ChapterNameValue": "Pennod {0}", - "Channels": "Sianeli", - "CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}", "Books": "Llyfrau", "AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus", "Artists": "Crewyr", "AppDeviceValues": "Ap: {0}, Dyfais: {1}", - "Albums": "Albwmau", "Genres": "Genres", "Folders": "Ffolderi", "Favorites": "Ffefrynnau", @@ -20,9 +15,7 @@ "TaskRefreshPeople": "Adnewyddu Pobl", "TasksChannelsCategory": "Sianeli Internet", "VersionNumber": "Fersiwn {0}", - "ScheduledTaskStartedWithName": "{0} wedi dechrau", "ScheduledTaskFailedWithName": "{0} wedi methu", - "ProviderValue": "Darparwr: {0}", "NotificationOptionInstallationFailed": "Fethu Gosod", "NameSeasonUnknown": "Tymor Anhysbys", "NameSeasonNumber": "Tymor {0}", @@ -30,31 +23,20 @@ "MixedContent": "Cynnwys amrywiol", "HomeVideos": "Genres", "HeaderNextUp": "Nesaf i Fyny", - "HeaderFavoriteArtists": "Ffefryn Artistiaid", - "HeaderFavoriteAlbums": "Ffefryn Albwmau", "HeaderContinueWatching": "Parhewch i Wylio", "TasksApplicationCategory": "Rhaglen", "TasksLibraryCategory": "Llyfrgell", "TasksMaintenanceCategory": "Cynnal a Chadw", - "System": "System", - "Plugin": "Ategyn", "Music": "Cerddoriaeth", "Latest": "Diweddaraf", "Inherit": "Etifeddu", "Forced": "Orfodi", - "Application": "Rhaglen", - "HeaderAlbumArtists": "Artistiaid albwm", - "Sync": "Cysoni", - "Songs": "Caneuon", "Shows": "Rhaglenni", - "Playlists": "Rhestri Chwarae", "Photos": "Lluniau", - "ValueSpecialEpisodeName": "Arbennig - {0}", "Movies": "Ffilmiau", "Undefined": "Heb ddiffiniad", "TvShows": "Rhaglenni teledu", "HeaderLiveTV": "Teledu Byw", - "User": "Defnyddiwr", "TaskCleanLogsDescription": "Dileu ffeiliau log sy'n fwy na {0} diwrnod oed.", "TaskCleanLogs": "Glanhau ffolder log", "TaskRefreshLibraryDescription": "Sganio'ch llyfrgell gyfryngau am ffeiliau newydd ac yn adnewyddu metaddata.", @@ -65,13 +47,9 @@ "NotificationOptionPluginError": "Methodd ategyn", "NotificationOptionAudioPlaybackStopped": "Stopiwyd chwarae sain", "NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain", - "MessageServerConfigurationUpdated": "Mae gosodiadau gweinydd wedi'i ddiweddaru", - "MessageNamedServerConfigurationUpdatedWithValue": "Mae adran gosodiadau gweinydd {0} wedi'i diweddaru", "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu o {0}", - "ValueHasBeenAddedToLibrary": "{0} wedi'i hychwanegu at eich llyfrgell gyfryngau", "UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}", "UserStartedPlayingItemWithValues": "{0} yn chwarae {1} ar {2}", - "UserPolicyUpdatedWithName": "Polisi defnyddiwr wedi'i newid ar gyfer {0}", "UserPasswordChangedWithName": "Cyfrinair wedi'i newid ar gyfer defnyddiwr {0}", "UserOnlineFromDevice": "Mae {0} ar-lein o {1}", "UserOfflineFromDevice": "Mae {0} wedi datgysylltu o {1}", @@ -80,7 +58,6 @@ "UserDeletedWithName": "Defnyddiwr {0} wedi'i ddileu", "UserCreatedWithName": "Defnyddiwr {0} wedi'i greu", "StartupEmbyServerIsLoading": "Gweinydd Jellyfin yn llwytho. Triwch eto mewn ychydig.", - "ServerNameNeedsToBeRestarted": "Mae angen ailddechrau {0}", "PluginUpdatedWithName": "{0} wedi'i ddiweddaru", "PluginUninstalledWithName": "{0} wedi'i ddadosod", "PluginInstalledWithName": "{0} wedi'i osod", @@ -98,13 +75,7 @@ "NotificationOptionApplicationUpdateAvailable": "Diweddariad ap ar gael", "NewVersionIsAvailable": "Mae fersiwn diweddarach o'r gweinydd Jellyfin ar gael.", "NameInstallFailed": "Gosodiad {0} wedi methu", - "MessageApplicationUpdatedTo": "Gweinydd Jellyfin wedi'i ddiweddaru i {0}", - "MessageApplicationUpdated": "Gweinydd Jellyfin wedi'i ddiweddaru", "LabelIpAddressValue": "Cyfeiriad IP: {0}", - "ItemRemovedWithName": "{0} wedi'i dynnu o'r llyfrgell", - "ItemAddedWithName": "{0} wedi'i adio i'r llyfrgell", - "HeaderRecordingGroups": "Grwpiau Recordio", - "HeaderFavoriteSongs": "Ffefryn Ganeuon", "HeaderFavoriteShows": "Ffefryn Shoeau", "HeaderFavoriteEpisodes": "Ffefryn Rhaglenni", "TaskDownloadMissingSubtitlesDescription": "Chwilio'r rhyngrwyd am is-deitlau coll yn seiliedig ar gosodiadau metaddata.", diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 7d905f3300..de56b6fd66 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -1,41 +1,24 @@ { - "Albums": "Albummer", "AppDeviceValues": "App: {0}, Enhed: {1}", - "Application": "Applikation", "Artists": "Kunstnere", "AuthenticationSucceededWithUserName": "{0} er logget ind", "Books": "Bøger", - "CameraImageUploadedFrom": "Et nyt kamerabillede er blevet uploadet fra {0}", - "Channels": "Kanaler", "ChapterNameValue": "Kapitel {0}", "Collections": "Samlinger", - "DeviceOfflineWithName": "{0} har afbrudt forbindelsen", - "DeviceOnlineWithName": "{0} er forbundet", "FailedLoginAttemptWithUserName": "Mislykket loginforsøg fra {0}", "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumkunstnere", - "HeaderContinueWatching": "Fortsæt afspilning", - "HeaderFavoriteAlbums": "Favoritalbum", - "HeaderFavoriteArtists": "Favoritkunstnere", + "HeaderContinueWatching": "Fortsæt med at se", "HeaderFavoriteEpisodes": "Yndlingsafsnit", "HeaderFavoriteShows": "Yndlingsserier", - "HeaderFavoriteSongs": "Yndlingssange", "HeaderLiveTV": "Live-TV", "HeaderNextUp": "Næste", - "HeaderRecordingGroups": "Optagelsesgrupper", "HomeVideos": "Hjemmevideoer", "Inherit": "Nedarv", - "ItemAddedWithName": "{0} blev tilføjet til biblioteket", - "ItemRemovedWithName": "{0} blev fjernet fra biblioteket", "LabelIpAddressValue": "IP-adresse: {0}", "LabelRunningTimeValue": "Spilletid: {0}", "Latest": "Seneste", - "MessageApplicationUpdated": "Jellyfin Server er blevet opdateret", - "MessageApplicationUpdatedTo": "Jellyfin Server er blevet opdateret til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfiguration sektion {0} er blevet opdateret", - "MessageServerConfigurationUpdated": "Serverkonfigurationen er blevet opdateret", "MixedContent": "Blandet indhold", "Movies": "Film", "Music": "Musik", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Videoafspilning påbegyndt", "NotificationOptionVideoPlaybackStopped": "Videoafspilning blev stoppet", "Photos": "Fotos", - "Playlists": "Afspilningslister", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} blev installeret", "PluginUninstalledWithName": "{0} blev afinstalleret", "PluginUpdatedWithName": "{0} blev opdateret", - "ProviderValue": "Udbyder: {0}", "ScheduledTaskFailedWithName": "{0} mislykkedes", - "ScheduledTaskStartedWithName": "{0} påbegyndte", - "ServerNameNeedsToBeRestarted": "{0} skal genstartes", "Shows": "Serier", - "Songs": "Sange", "StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.", "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}", - "Sync": "Synkroniser", - "System": "System", "TvShows": "TV-serier", - "User": "Bruger", "UserCreatedWithName": "Bruger {0} er blevet oprettet", "UserDeletedWithName": "Brugeren {0} er nu slettet", "UserDownloadingItemWithValues": "{0} henter {1}", @@ -85,16 +59,13 @@ "UserOfflineFromDevice": "{0} har afbrudt fra {1}", "UserOnlineFromDevice": "{0} er online fra {1}", "UserPasswordChangedWithName": "Adgangskode er ændret for brugeren {0}", - "UserPolicyUpdatedWithName": "Brugerpolitikken er blevet opdateret for {0}", "UserStartedPlayingItemWithValues": "{0} afspiller {1} på {2}", "UserStoppedPlayingItemWithValues": "{0} har afsluttet afspilning af {1} på {2}", - "ValueHasBeenAddedToLibrary": "{0} er blevet tilføjet til dit mediebibliotek", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "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.", @@ -108,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", @@ -119,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", @@ -128,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/de.json b/Emby.Server.Implementations/Localization/Core/de.json index ab1a7d2cbd..8ac5fdf6fc 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -1,41 +1,24 @@ { - "Albums": "Alben", "AppDeviceValues": "App: {0}, Gerät: {1}", - "Application": "Anwendung", "Artists": "Interpreten", "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", - "CameraImageUploadedFrom": "Ein neues Kamerabild wurde von {0} hochgeladen", - "Channels": "Kanäle", "ChapterNameValue": "Kapitel {0}", "Collections": "Sammlungen", - "DeviceOfflineWithName": "{0} ist offline", - "DeviceOnlineWithName": "{0} ist online", "FailedLoginAttemptWithUserName": "Anmeldung von {0} fehlgeschlagen", "Favorites": "Favoriten", "Folders": "Verzeichnisse", "Genres": "Genres", - "HeaderAlbumArtists": "Album-Interpreten", "HeaderContinueWatching": "Weiterschauen", - "HeaderFavoriteAlbums": "Lieblingsalben", - "HeaderFavoriteArtists": "Lieblingsinterpreten", "HeaderFavoriteEpisodes": "Lieblingsfolgen", "HeaderFavoriteShows": "Lieblingsserien", - "HeaderFavoriteSongs": "Lieblingssongs", "HeaderLiveTV": "Live TV", "HeaderNextUp": "Als Nächstes", - "HeaderRecordingGroups": "Aufnahme-Gruppen", "HomeVideos": "Heimvideos", "Inherit": "Vererben", - "ItemAddedWithName": "{0} wurde der Bibliothek hinzugefügt", - "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt", "LabelIpAddressValue": "IP-Adresse: {0}", "LabelRunningTimeValue": "Laufzeit: {0}", "Latest": "Neueste", - "MessageApplicationUpdated": "Jellyfin-Server wurde aktualisiert", - "MessageApplicationUpdatedTo": "Jellyfin-Server wurde auf Version {0} aktualisiert", - "MessageNamedServerConfigurationUpdatedWithValue": "Der Server-Einstellungsbereich {0} wurde aktualisiert", - "MessageServerConfigurationUpdated": "Servereinstellungen wurden aktualisiert", "MixedContent": "Gemischte Inhalte", "Movies": "Filme", "Music": "Musik", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Video wird abgespielt", "NotificationOptionVideoPlaybackStopped": "Videowiedergabe gestoppt", "Photos": "Fotos", - "Playlists": "Wiedergabelisten", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} wurde installiert", "PluginUninstalledWithName": "{0} wurde deinstalliert", "PluginUpdatedWithName": "{0} wurde aktualisiert", - "ProviderValue": "Anbieter: {0}", "ScheduledTaskFailedWithName": "{0} ist fehlgeschlagen", - "ScheduledTaskStartedWithName": "{0} wurde gestartet", - "ServerNameNeedsToBeRestarted": "{0} muss neu gestartet werden", "Shows": "Serien", - "Songs": "Lieder", "StartupEmbyServerIsLoading": "Jellyfin-Server lädt. Bitte versuche es gleich noch einmal.", "SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden", - "Sync": "Synchronisation", - "System": "System", "TvShows": "Serien", - "User": "Benutzer", "UserCreatedWithName": "Benutzer {0} wurde erstellt", "UserDeletedWithName": "Benutzer {0} wurde gelöscht", "UserDownloadingItemWithValues": "{0} lädt {1} herunter", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} wurde getrennt von {1}", "UserOnlineFromDevice": "{0} ist online von {1}", "UserPasswordChangedWithName": "Das Passwort für Benutzer {0} wurde geändert", - "UserPolicyUpdatedWithName": "Benutzerrichtlinie von {0} wurde aktualisiert", "UserStartedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} gestartet", "UserStoppedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} beendet", - "ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt", - "ValueSpecialEpisodeName": "Extra – {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Sucht im Internet basierend auf den Metadaten-Einstellungen nach fehlenden Untertiteln.", "TaskDownloadMissingSubtitles": "Fehlende Untertitel herunterladen", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren", "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.", "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", - "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind." + "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 0443207ea6..c0ad2c165a 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -1,41 +1,24 @@ { - "Albums": "Άλμπουμ", "AppDeviceValues": "Εφαρμογή: {0}, Συσκευή: {1}", - "Application": "Εφαρμογή", "Artists": "Καλλιτέχνες", "AuthenticationSucceededWithUserName": "Ο χρήστης {0} επαληθεύτηκε επιτυχώς", "Books": "Βιβλία", - "CameraImageUploadedFrom": "Μια νέα φωτογραφία φορτώθηκε από {0}", - "Channels": "Κανάλια", "ChapterNameValue": "Κεφάλαιο {0}", "Collections": "Συλλογές", - "DeviceOfflineWithName": "Ο/Η {0} αποσυνδέθηκε", - "DeviceOnlineWithName": "Ο/Η {0} συνδέθηκε", "FailedLoginAttemptWithUserName": "Αποτυχία προσπάθειας σύνδεσης από {0}", "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", - "HeaderAlbumArtists": "Καλλιτέχνες άλμπουμ", "HeaderContinueWatching": "Συνεχίστε την παρακολούθηση", - "HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ", - "HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες", "HeaderFavoriteEpisodes": "Αγαπημένα Επεισόδια", "HeaderFavoriteShows": "Αγαπημένες Σειρές", - "HeaderFavoriteSongs": "Αγαπημένα Τραγούδια", "HeaderLiveTV": "Ζωντανή Τηλεόραση", "HeaderNextUp": "Επόμενο", - "HeaderRecordingGroups": "Ομάδες Ηχογράφησης", "HomeVideos": "Προσωπικά Βίντεο", "Inherit": "Κληρονόμηση", - "ItemAddedWithName": "Το {0} προστέθηκε στη βιβλιοθήκη", - "ItemRemovedWithName": "Το {0} διαγράφτηκε από τη βιβλιοθήκη", "LabelIpAddressValue": "Διεύθυνση IP: {0}", "LabelRunningTimeValue": "Διάρκεια: {0}", "Latest": "Πρόσφατα", - "MessageApplicationUpdated": "Ο διακομιστής Jellyfin έχει ενημερωθεί", - "MessageApplicationUpdatedTo": "Ο διακομιστής Jellyfin αναβαθμίστηκε στην έκδοση {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Η ενότητα {0} ρύθμισης παραμέτρων του διακομιστή έχει ενημερωθεί", - "MessageServerConfigurationUpdated": "Η ρύθμιση παραμέτρων του διακομιστή έχει ενημερωθεί", "MixedContent": "Ανάμεικτο Περιεχόμενο", "Movies": "Ταινίες", "Music": "Μουσική", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Η αναπαραγωγή βίντεο ξεκίνησε", "NotificationOptionVideoPlaybackStopped": "Η αναπαραγωγή βίντεο σταμάτησε", "Photos": "Φωτογραφίες", - "Playlists": "Λίστες αναπαραγωγής", - "Plugin": "Πρόσθετο", "PluginInstalledWithName": "Το {0} εγκαταστάθηκε", "PluginUninstalledWithName": "Το {0} έχει απεγκατασταθεί", "PluginUpdatedWithName": "Το {0} ενημερώθηκε", - "ProviderValue": "Πάροχος: {0}", "ScheduledTaskFailedWithName": "{0} αποτυχία", - "ScheduledTaskStartedWithName": "{0} ξεκίνησε", - "ServerNameNeedsToBeRestarted": "{0} χρειάζεται επανεκκίνηση", "Shows": "Σειρές", - "Songs": "Τραγούδια", "StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.", - "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}", - "Sync": "Συγχρονισμός", - "System": "Σύστημα", + "SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}", "TvShows": "Τηλεοπτικές Σειρές", - "User": "Χρήστης", "UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε", "UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί", "UserDownloadingItemWithValues": "{0} κατεβάζει {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} αποσυνδέθηκε από {1}", "UserOnlineFromDevice": "{0} είναι online απο {1}", "UserPasswordChangedWithName": "Ο κωδικός του χρήστη {0} έχει αλλάξει", - "UserPolicyUpdatedWithName": "Η πολιτική χρήστη έχει ενημερωθεί για {0}", "UserStartedPlayingItemWithValues": "{0} παίζει {1} σε {2}", "UserStoppedPlayingItemWithValues": "{0} τελείωσε να παίζει {1} σε {2}", - "ValueHasBeenAddedToLibrary": "{0} προστέθηκαν στη βιβλιοθήκη πολυμέσων σας", - "ValueSpecialEpisodeName": "Σπέσιαλ - {0}", "VersionNumber": "Έκδοση {0}", "TaskRefreshPeople": "Ανανέωση Ατόμων", "TaskCleanLogsDescription": "Διαγράφει αρχεία καταγραφής που είναι πάνω από {0} ημέρες.", @@ -135,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 b0094e33c3..298d60d277 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -1,41 +1,24 @@ { - "Albums": "Albums", "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", "Artists": "Artists", "AuthenticationSucceededWithUserName": "{0} successfully authenticated", "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", "ChapterNameValue": "Chapter {0}", "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", "Favorites": "Favourites", "Folders": "Folders", "Genres": "Genres", - "HeaderAlbumArtists": "Album artists", "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favourite Albums", - "HeaderFavoriteArtists": "Favourite Artists", "HeaderFavoriteEpisodes": "Favourite Episodes", "HeaderFavoriteShows": "Favourite Shows", - "HeaderFavoriteSongs": "Favourite Songs", "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", "HomeVideos": "Home Videos", "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", "LabelIpAddressValue": "IP address: {0}", "LabelRunningTimeValue": "Running time: {0}", "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Video playback started", "NotificationOptionVideoPlaybackStopped": "Video playback stopped", "Photos": "Photos", - "Playlists": "Playlists", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} was installed", "PluginUninstalledWithName": "{0} was uninstalled", "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", "Shows": "Shows", - "Songs": "Songs", "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "Sync": "Sync", - "System": "System", "TvShows": "TV Shows", - "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", "UserDownloadingItemWithValues": "{0} is downloading {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} has disconnected from {1}", "UserOnlineFromDevice": "{0} is online from {1}", "UserPasswordChangedWithName": "Password has been changed for user {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", "UserStartedPlayingItemWithValues": "{0} has started playing {1}", "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration.", "TaskDownloadMissingSubtitles": "Download missing subtitles", @@ -135,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/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 45b1cbb6a0..856941c61a 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -1,45 +1,29 @@ { - "Albums": "Albums", "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", "Artists": "Artists", "AuthenticationSucceededWithUserName": "{0} successfully authenticated", "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", "ChapterNameValue": "Chapter {0}", "Collections": "Collections", "Default": "Default", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", "External": "External", "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", "Favorites": "Favorites", "Folders": "Folders", "Forced": "Forced", "Genres": "Genres", - "HeaderAlbumArtists": "Album artists", "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", "HeaderFavoriteEpisodes": "Favorite Episodes", "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", "HearingImpaired": "Hearing Impaired", "HomeVideos": "Home Videos", "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", "LabelIpAddressValue": "IP address: {0}", "LabelRunningTimeValue": "Running time: {0}", "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", + "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}", "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", @@ -64,25 +48,17 @@ "NotificationOptionUserLockedOut": "User locked out", "NotificationOptionVideoPlayback": "Video playback started", "NotificationOptionVideoPlaybackStopped": "Video playback stopped", + "Original": "Original", "Photos": "Photos", - "Playlists": "Playlists", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} was installed", "PluginUninstalledWithName": "{0} was uninstalled", "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", "Shows": "Shows", - "Songs": "Songs", "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "Sync": "Sync", - "System": "System", "TvShows": "TV Shows", "Undefined": "Undefined", - "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", "UserDownloadingItemWithValues": "{0} is downloading {1}", @@ -90,11 +66,8 @@ "UserOfflineFromDevice": "{0} has disconnected from {1}", "UserOnlineFromDevice": "{0} is online from {1}", "UserPasswordChangedWithName": "Password has been changed for user {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "TasksMaintenanceCategory": "Maintenance", "TasksLibraryCategory": "Library", 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/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json index 42cce1096f..133a2755a8 100644 --- a/Emby.Server.Implementations/Localization/Core/eo.json +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -7,35 +7,22 @@ "NameInstallFailed": "{0} instalado fiaskis", "Music": "Muziko", "Movies": "Filmoj", - "ItemRemovedWithName": "{0} forigis el la plurmediteko", - "ItemAddedWithName": "{0} aldonis al la plurmediteko", "HeaderLiveTV": "TV-etero", "HeaderContinueWatching": "Daŭrigi Spektadon", - "HeaderAlbumArtists": "Artistoj de albumo", "Folders": "Dosierujoj", - "DeviceOnlineWithName": "{0} estas konektita", "Default": "Defaŭlte", "Collections": "Kolektoj", "ChapterNameValue": "Ĉapitro {0}", - "Channels": "Kanaloj", "Books": "Libroj", "Artists": "Artistoj", - "Application": "Aplikaĵo", "AppDeviceValues": "Aplikaĵo: {0}, Aparato: {1}", - "Albums": "Albumoj", "TasksLibraryCategory": "Plurmediteko", "VersionNumber": "Versio {0}", "UserDownloadingItemWithValues": "{0} elŝutas {1}", "UserCreatedWithName": "Uzanto {0} kreiĝis", - "User": "Uzanto", - "System": "Sistemo", - "Songs": "Kantoj", - "ScheduledTaskStartedWithName": "{0} lanĉis", "ScheduledTaskFailedWithName": "{0} malsukcesis", "PluginUninstalledWithName": "{0} malinstaliĝis", "PluginInstalledWithName": "{0} instaliĝis", - "Plugin": "Kromprogramo", - "Playlists": "Ludlistoj", "Photos": "Fotoj", "NotificationOptionPluginUninstalled": "Kromprogramo malinstaliĝis", "NotificationOptionNewLibraryContent": "Nova enhavo aldoniĝis", @@ -43,36 +30,28 @@ "MusicVideos": "Muzikvideoj", "LabelIpAddressValue": "IP-adreso: {0}", "Genres": "Ĝenroj", - "DeviceOfflineWithName": "{0} malkonektis", - "HeaderFavoriteArtists": "Favorataj Artistoj", "Shows": "Serioj", "HeaderFavoriteShows": "Favorataj Serioj", "TvShows": "TV-serioj", "Favorites": "Favorataj", "TaskCleanLogs": "Purigi Ĵurnalan Katalogon", "TaskRefreshLibrary": "Skani Plurmeditekon", - "ValueSpecialEpisodeName": "Speciala - {0}", "TaskOptimizeDatabase": "Optimumigi datenbazon", "TaskRefreshChannels": "Refreŝigi Kanalojn", "TaskUpdatePlugins": "Ĝisdatigi Kromprogramojn", "TaskRefreshPeople": "Refreŝigi Homojn", "TasksChannelsCategory": "Interretaj Kanaloj", - "ProviderValue": "Provizanto: {0}", "NotificationOptionPluginError": "Kromprogramo malsukcesis", "MixedContent": "Miksita enhavo", "TasksApplicationCategory": "Aplikaĵo", "TasksMaintenanceCategory": "Prizorgado", "Undefined": "Nedifinita", - "Sync": "Sinkronigo", "Latest": "Plej novaj", "Inherit": "Hereda", "HomeVideos": "Hejmaj Videoj", "HeaderNextUp": "Sekva Plue", - "HeaderFavoriteSongs": "Favorataj Kantoj", "HeaderFavoriteEpisodes": "Favorataj Epizodoj", - "HeaderFavoriteAlbums": "Favorataj Albumoj", "Forced": "Forcita", - "ServerNameNeedsToBeRestarted": "{0} devas esti relanĉita", "NotificationOptionVideoPlayback": "La videoludado lanĉis", "NotificationOptionServerRestartRequired": "Servila relanĉigo bezonata", "TaskOptimizeDatabaseDescription": "Kompaktigas datenbazon kaj trunkas liberan lokon. Lanĉi ĉi tiun taskon post la plurmediteka skanado aŭ fari aliajn ŝanĝojn, kiuj implicas datenbazajn modifojn, povus plibonigi rendimenton.", @@ -85,22 +64,16 @@ "TaskCleanCacheDescription": "Forigas stapla dosierojn ne plu necesajn de la sistemo.", "TaskCleanActivityLogDescription": "Forigas aktivecan ĵurnalaĵojn pli malnovajn ol la agordita aĝo.", "TaskCleanTranscodeDescription": "Forigas transkodajn dosierojn aĝajn pli ol unu tagon.", - "ValueHasBeenAddedToLibrary": "{0} estis aldonita al via plurmediteko", "SubtitleDownloadFailureFromForItem": "Subtekstoj malsukcesis elŝuti de {0} por {1}", "StartupEmbyServerIsLoading": "Jellyfin Server ŝarĝas. Provi denove baldaŭ.", "TaskRefreshChapterImagesDescription": "Kreas bildetojn por videoj kiuj havas ĉapitrojn.", "UserStoppedPlayingItemWithValues": "{0} finis ludi {1} ĉe {2}", - "UserPolicyUpdatedWithName": "Uzanta politiko estis ĝisdatigita por {0}", "UserPasswordChangedWithName": "Pasvorto estis ŝanĝita por uzanto {0}", "UserStartedPlayingItemWithValues": "{0} ludas {1} ĉe {2}", "UserLockedOutWithName": "Uzanto {0} estas elŝlosita", "UserOnlineFromDevice": "{0} estas enreta de {1}", "UserOfflineFromDevice": "{0} malkonektis de {1}", "UserDeletedWithName": "Uzanto {0} estis forigita", - "MessageServerConfigurationUpdated": "Servila agordaro estis ĝisdatigita", - "MessageNamedServerConfigurationUpdatedWithValue": "Servila agorda sekcio {0} estis ĝisdatigita", - "MessageApplicationUpdatedTo": "Jellyfin Server estis ĝisdatigita al {0}", - "MessageApplicationUpdated": "Jellyfin Server estis ĝisdatigita", "TaskRefreshChannelsDescription": "Refreŝigas informon pri interretaj kanaloj.", "TaskDownloadMissingSubtitles": "Elŝuti mankantajn subtekstojn", "TaskCleanTranscode": "Malplenigi Transkodadan Katalogon", @@ -116,9 +89,7 @@ "NotificationOptionApplicationUpdateInstalled": "Aplikaĵa ĝisdatigo instalita", "NotificationOptionApplicationUpdateAvailable": "Ĝisdatigo de aplikaĵo havebla", "LabelRunningTimeValue": "Ludada tempo: {0}", - "HeaderRecordingGroups": "Rikordadaj Grupoj", "FailedLoginAttemptWithUserName": "Malsukcesa ensaluta provo de {0}", - "CameraImageUploadedFrom": "Nova kamera bildo estis alŝutita de {0}", "AuthenticationSucceededWithUserName": "{0} sukcese aŭtentikigis", "TaskKeyframeExtractorDescription": "Eltiras ĉefkadrojn el videodosieroj por krei pli precizajn HLS-ludlistojn. Ĉi tiu tasko povas funkcii dum longa tempo.", "TaskKeyframeExtractor": "Eltiri Ĉefkadrojn", diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 7fda507797..bccfdd4c19 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -1,41 +1,24 @@ { - "Albums": "Álbumes", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", - "Application": "Aplicación", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado correctamente", "Books": "Libros", - "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", - "Channels": "Canales", "ChapterNameValue": "Capítulo {0}", "Collections": "Colecciones", - "DeviceOfflineWithName": "{0} se ha desconectado", - "DeviceOnlineWithName": "{0} está conectado", "FailedLoginAttemptWithUserName": "Error al intentar iniciar sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", "HeaderContinueWatching": "Seguir viendo", - "HeaderFavoriteAlbums": "Álbumes favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Capítulos favoritos", "HeaderFavoriteShows": "Series favoritas", - "HeaderFavoriteSongs": "Canciones favoritas", "HeaderLiveTV": "TV en vivo", "HeaderNextUp": "Siguiente", - "HeaderRecordingGroups": "Grupos de grabación", "HomeVideos": "Videos caseros", "Inherit": "Heredar", - "ItemAddedWithName": "{0} se ha añadido a la biblioteca", - "ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "LabelRunningTimeValue": "Tiempo de funcionamiento: {0}", "Latest": "Últimos", - "MessageApplicationUpdated": "El servidor Jellyfin fue actualizado", - "MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Se inició la reproducción de video", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "Photos": "Fotos", - "Playlists": "Listas de reproducción", - "Plugin": "Complemento", "PluginInstalledWithName": "{0} fue instalado", "PluginUninstalledWithName": "{0} fue desinstalado", "PluginUpdatedWithName": "{0} fue actualizado", - "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", - "ScheduledTaskStartedWithName": "{0} iniciado", - "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Series", - "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Series de TV", - "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", "UserDownloadingItemWithValues": "{0} está descargando {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} se ha desconectado de {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "UserPolicyUpdatedWithName": "Las política de usuario ha sido actualizada para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "ValueHasBeenAddedToLibrary": "{0} ha sido añadido a tu biblioteca multimedia", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versión {0}", "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten basándose en la configuración de los metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", @@ -135,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-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index d03d3ed2ff..ac489b9e77 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -1,41 +1,24 @@ { - "Albums": "Álbumes", "AppDeviceValues": "App: {0}, Dispositivo: {1}", - "Application": "Aplicación", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", "Books": "Libros", - "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}", - "Channels": "Canales", "ChapterNameValue": "Capítulo {0}", "Collections": "Colecciones", - "DeviceOfflineWithName": "{0} se ha desconectado", - "DeviceOnlineWithName": "{0} está conectado", "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del Álbum", "HeaderContinueWatching": "Continuar viendo", - "HeaderFavoriteAlbums": "Álbumes favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", "HeaderFavoriteShows": "Programas favoritos", - "HeaderFavoriteSongs": "Canciones favoritas", "HeaderLiveTV": "TV en vivo", "HeaderNextUp": "A continuación", - "HeaderRecordingGroups": "Grupos de grabación", "HomeVideos": "Videos Caseros", "Inherit": "Heredar", - "ItemAddedWithName": "{0} fue agregado a la biblioteca", - "ItemRemovedWithName": "{0} fue removido de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "LabelRunningTimeValue": "Tiempo corriendo: {0}", "Latest": "Recientes", - "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", - "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Reproducción de video iniciada", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "Photos": "Fotos", - "Playlists": "Listas de reproducción", - "Plugin": "Complemento", "PluginInstalledWithName": "{0} fue instalado", "PluginUninstalledWithName": "{0} fue desinstalado", "PluginUpdatedWithName": "{0} fue actualizado", - "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", - "ScheduledTaskStartedWithName": "{0} iniciado", - "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Programas", - "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Programas de TV", - "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido eliminado", "UserDownloadingItemWithValues": "{0} está descargando {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versión {0}", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index cf118077c6..563dce8fe6 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -1,41 +1,24 @@ { - "Albums": "Álbumes", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", - "Application": "Aplicación", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado correctamente", "Books": "Libros", - "CameraImageUploadedFrom": "Se ha subido una nueva imagen por cámara desde {0}", - "Channels": "Canales", "ChapterNameValue": "Capítulo {0}", "Collections": "Colecciones", - "DeviceOfflineWithName": "{0} se ha desconectado", - "DeviceOnlineWithName": "{0} está conectado", "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", "HeaderContinueWatching": "Seguir viendo", - "HeaderFavoriteAlbums": "Álbumes favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", "HeaderFavoriteShows": "Series favoritas", - "HeaderFavoriteSongs": "Canciones favoritas", "HeaderLiveTV": "Televisión en directo", "HeaderNextUp": "Siguiente", - "HeaderRecordingGroups": "Grupos de grabación", "HomeVideos": "Vídeos caseros", "Inherit": "Heredar", - "ItemAddedWithName": "{0} se ha añadido a la biblioteca", - "ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "LabelRunningTimeValue": "Duración: {0}", "Latest": "Últimas", - "MessageApplicationUpdated": "Se ha actualizado el servidor Jellyfin", - "MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de configuración del servidor ha sido actualizada", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "MixedContent": "Contenido mixto", "Movies": "Películas", "Music": "Música", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Se inició la reproducción de vídeo", "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo detenida", "Photos": "Fotos", - "Playlists": "Listas de reproducción", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} se ha instalado", "PluginUninstalledWithName": "{0} se ha desinstalado", "PluginUpdatedWithName": "{0} se actualizó", - "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", - "ScheduledTaskStartedWithName": "{0} iniciada", - "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Series", - "Songs": "Canciones", "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.", "SubtitleDownloadFailureFromForItem": "Fallo en la descarga de subtítulos desde {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Series", - "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", "UserDownloadingItemWithValues": "{0} está descargando {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "UserPolicyUpdatedWithName": "Actualizada política de usuario para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "ValueHasBeenAddedToLibrary": "{0} ha sido añadido a tu biblioteca multimedia", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versión {0}", "TasksMaintenanceCategory": "Mantenimiento", "TasksLibraryCategory": "Biblioteca", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.", "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." + "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.", + "Original": "Original", + "LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index dec82b73e3..4404354a88 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -1,29 +1,19 @@ { "LabelRunningTimeValue": "Tiempo en ejecución: {0}", - "ValueSpecialEpisodeName": "Especial - {0}", - "Sync": "Sincronizar", - "Songs": "Canciones", "Shows": "Programas", - "Playlists": "Listas de reproducción", "Photos": "Fotos", "Movies": "Películas", "HeaderNextUp": "A continuación", "HeaderLiveTV": "TV en vivo", - "HeaderFavoriteSongs": "Canciones favoritas", - "HeaderFavoriteArtists": "Artistas favoritos", - "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", "HeaderFavoriteShows": "Programas favoritos", "HeaderContinueWatching": "Continuar viendo", - "HeaderAlbumArtists": "Artistas de álbum", "Genres": "Géneros", "Folders": "Carpetas", "Favorites": "Favoritos", "Collections": "Colecciones", - "Channels": "Canales", "Books": "Libros", "Artists": "Artistas", - "Albums": "Álbumes", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.", @@ -47,10 +37,8 @@ "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Mantenimiento", "VersionNumber": "Versión {0}", - "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", - "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", @@ -58,19 +46,13 @@ "UserDownloadingItemWithValues": "{0} está descargando {1}", "UserDeletedWithName": "El usuario {0} ha sido eliminado", "UserCreatedWithName": "El usuario {0} ha sido creado", - "User": "Usuario", "TvShows": "Programas de TV", - "System": "Sistema", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", - "ScheduledTaskStartedWithName": "{0} iniciado", "ScheduledTaskFailedWithName": "{0} falló", - "ProviderValue": "Proveedor: {0}", "PluginUpdatedWithName": "{0} fue actualizado", "PluginUninstalledWithName": "{0} fue desinstalado", "PluginInstalledWithName": "{0} fue instalado", - "Plugin": "Complemento", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "NotificationOptionVideoPlayback": "Reproducción de video iniciada", "NotificationOptionUserLockedOut": "Usuario bloqueado", @@ -94,24 +76,13 @@ "MusicVideos": "Videos musicales", "Music": "Música", "MixedContent": "Contenido mezclado", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", - "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", - "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", - "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", "Latest": "Recientes", "LabelIpAddressValue": "Dirección IP: {0}", - "ItemRemovedWithName": "{0} fue removido de la biblioteca", - "ItemAddedWithName": "{0} fue agregado a la biblioteca", "Inherit": "Heredar", "HomeVideos": "Videos caseros", - "HeaderRecordingGroups": "Grupos de grabación", "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}", - "DeviceOnlineWithName": "{0} está conectado", - "DeviceOfflineWithName": "{0} se ha desconectado", "ChapterNameValue": "Capítulo {0}", - "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", - "Application": "Aplicación", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", "TaskCleanActivityLog": "Limpiar registro de actividades", @@ -135,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/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json index 8d991fa74a..a1b9944125 100644 --- a/Emby.Server.Implementations/Localization/Core/es_DO.json +++ b/Emby.Server.Implementations/Localization/Core/es_DO.json @@ -1,35 +1,24 @@ { - "Channels": "Canales", "Books": "Libros", - "Albums": "Álbumes", "Collections": "Colecciones", "Artists": "Artistas", - "DeviceOnlineWithName": "{0} está conectado", - "DeviceOfflineWithName": "{0} se ha desconectado", "ChapterNameValue": "Capítulo {0}", - "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", - "Application": "Aplicación", "AppDeviceValues": "App: {0}, Dispositivo: {1}", "HeaderContinueWatching": "Continuar Viendo", - "HeaderAlbumArtists": "Artistas del álbum", "Genres": "Géneros", "Folders": "Carpetas", "Favorites": "Favoritos", "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}", - "HeaderFavoriteSongs": "Canciones Favoritas", "HeaderFavoriteEpisodes": "Episodios Favoritos", - "HeaderFavoriteArtists": "Artistas Favoritos", "External": "Externo", "Default": "Predeterminado", "Movies": "Películas", - "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de la configuración ha sido actualizada", "MixedContent": "Contenido mixto", "Music": "Música", "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor", "NotificationOptionVideoPlayback": "Reproducción de video iniciada", - "Sync": "Sincronizar", "Shows": "Series", "UserDownloadingItemWithValues": "{0} está descargando {1}", "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", @@ -48,17 +37,12 @@ "HeaderFavoriteShows": "Programas favoritos", "TaskCleanActivityLog": "Limpiar registro de actividades", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "System": "Sistema", - "User": "Usuario", "Forced": "Forzado", "PluginInstalledWithName": "{0} ha sido instalado", - "HeaderFavoriteAlbums": "Álbumes favoritos", "TaskUpdatePlugins": "Actualizar Plugins", "Latest": "Recientes", "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", - "Songs": "Canciones", "NotificationOptionPluginError": "Falla de plugin", - "ScheduledTaskStartedWithName": "{0} iniciado", "TasksApplicationCategory": "Aplicación", "UserDeletedWithName": "El usuario {0} ha sido eliminado", "TaskRefreshChapterImages": "Extraer imágenes de los capítulos", @@ -71,34 +55,26 @@ "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida", "TasksLibraryCategory": "Biblioteca", "NotificationOptionPluginInstalled": "Plugin instalado", - "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", "VersionNumber": "Versión {0}", "HeaderNextUp": "A continuación", - "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "NameSeasonNumber": "Temporada {0}", "NotificationOptionNewLibraryContent": "Nuevo contenido agregado", - "Plugin": "Plugin", "NotificationOptionAudioPlayback": "Reproducción de audio iniciada", "NotificationOptionTaskFailed": "Falló la tarea programada", "LabelRunningTimeValue": "Tiempo en ejecución: {0}", "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", "TaskRefreshLibrary": "Escanear biblioteca de medios", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", "TasksMaintenanceCategory": "Mantenimiento", - "ProviderValue": "Proveedor: {0}", "UserCreatedWithName": "El usuario {0} ha sido creado", "PluginUninstalledWithName": "{0} ha sido desinstalado", - "ValueSpecialEpisodeName": "Especial - {0}", "ScheduledTaskFailedWithName": "{0} falló", "TaskCleanLogs": "Limpiar directorio de registros", "NameInstallFailed": "Falló la instalación de {0}", "UserLockedOutWithName": "El usuario {0} ha sido bloqueado", "TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios para encontrar archivos nuevos y actualizar los metadatos.", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo en un momento.", - "Playlists": "Listas de reproducción", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", - "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", "TaskRefreshPeople": "Actualizar personas", "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", "HeaderLiveTV": "TV en vivo", @@ -108,15 +84,10 @@ "TaskCleanCache": "Limpiar directorio caché", "TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.", "Inherit": "Heredar", - "HeaderRecordingGroups": "Grupos de grabación", - "ItemAddedWithName": "{0} fue agregado a la biblioteca", "TaskOptimizeDatabase": "Optimizar base de datos", "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", "HearingImpaired": "Discapacidad auditiva", "HomeVideos": "Videos caseros", - "ItemRemovedWithName": "{0} fue removido de la biblioteca", - "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", - "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", "MusicVideos": "Videos musicales", "NewVersionIsAvailable": "Una nueva versión de Jellyfin está disponible para descargar.", "PluginUpdatedWithName": "{0} ha sido actualizado", diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index d751e35af2..e6bf1f25b5 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -1,7 +1,6 @@ { "TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.", "UserDownloadingItemWithValues": "{0} laadib alla {1}", - "HeaderRecordingGroups": "Salvestusrühmad", "TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.", "TaskOptimizeDatabase": "Optimeeri andmebaasi", "TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.", @@ -29,30 +28,19 @@ "TasksLibraryCategory": "Meediakogu", "TasksMaintenanceCategory": "Hooldus", "VersionNumber": "Versioon {0}", - "ValueSpecialEpisodeName": "Eriepisood - {0}", - "ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse", "UserStartedPlayingItemWithValues": "{0} taasesitab {1} seadmes {2}", "UserPasswordChangedWithName": "Kasutaja {0} parool muudeti", "UserLockedOutWithName": "Kasutaja {0} lukustati", "UserDeletedWithName": "Kasutaja {0} kustutati", "UserCreatedWithName": "Kasutaja {0} on loodud", - "ScheduledTaskStartedWithName": "{0} käivitati", - "ProviderValue": "Allikas: {0}", "StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.", - "User": "Kasutaja", "Undefined": "Määratlemata", "TvShows": "Sarjad", - "System": "Süsteem", - "Sync": "Sünkrooni", - "Songs": "Lood", "Shows": "Sarjad", - "ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada", "ScheduledTaskFailedWithName": "{0} nurjus", "PluginUpdatedWithName": "{0} uuendati", "PluginUninstalledWithName": "{0} eemaldati", "PluginInstalledWithName": "{0} paigaldati", - "Plugin": "Plugin", - "Playlists": "Esitusloendid", "Photos": "Fotod", "NotificationOptionVideoPlaybackStopped": "Video taasesitus lõppes", "NotificationOptionVideoPlayback": "Video taasesitus algas", @@ -78,46 +66,29 @@ "Music": "Muusika", "Movies": "Filmid", "MixedContent": "Segatud sisu", - "MessageServerConfigurationUpdated": "Serveri seadistust uuendati", - "MessageNamedServerConfigurationUpdatedWithValue": "Serveri seadistusosa {0} uuendati", - "MessageApplicationUpdatedTo": "Jellyfin server uuendati versioonile {0}", - "MessageApplicationUpdated": "Jellyfin server uuendati", "Latest": "Uusimad", "LabelRunningTimeValue": "Kestus: {0}", "LabelIpAddressValue": "IP aadress: {0}", - "ItemRemovedWithName": "{0} eemaldati meediakogust", - "ItemAddedWithName": "{0} lisati meediakogusse", "Inherit": "Päri", "HomeVideos": "Koduvideod", "HeaderNextUp": "Järgmisena", "HeaderLiveTV": "Otse TV", - "HeaderFavoriteSongs": "Lemmiklood", "HeaderFavoriteShows": "Lemmiksarjad", "HeaderFavoriteEpisodes": "Lemmikepisoodid", - "HeaderFavoriteArtists": "Lemmikesitajad", - "HeaderFavoriteAlbums": "Lemmikalbumid", "HeaderContinueWatching": "Jätka vaatamist", - "HeaderAlbumArtists": "Albumi esitajad", "Genres": "Žanrid", "Forced": "Sunnitud", "Folders": "Kaustad", "Favorites": "Lemmikud", "FailedLoginAttemptWithUserName": "Sisselogimine nurjus aadressilt {0}", - "DeviceOnlineWithName": "{0} on ühendatud", - "DeviceOfflineWithName": "{0} katkestas ühenduse", "Default": "Vaikimisi", "ChapterNameValue": "Peatükk {0}", - "Channels": "Kanalid", - "CameraImageUploadedFrom": "Uus kaamera pilt laaditi üles allikalt {0}", "Books": "Raamatud", "AuthenticationSucceededWithUserName": "{0} autentimine õnnestus", "Artists": "Esitajad", - "Application": "Rakendus", "AppDeviceValues": "Rakendus: {0}, seade: {1}", - "Albums": "Albumid", "UserOfflineFromDevice": "{0} katkestas ühenduse seadmega {1}", "SubtitleDownloadFailureFromForItem": "Subtiitrite allalaadimine {0} > {1} nurjus", - "UserPolicyUpdatedWithName": "Kasutaja {0} õigusi värskendati", "UserStoppedPlayingItemWithValues": "{0} lõpetas {1} taasesituse seadmes {2}", "UserOnlineFromDevice": "{0} on ühendatud seadmest {1}", "External": "Väline", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meedialõigud MediaSegment'i toega pluginatest.", "TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht", "CleanupUserDataTask": "Puhasta kasutajaandmed", - "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud." + "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.", + "LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus", + "Original": "Algne" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index 9e1390484f..71c351adcd 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -1,23 +1,16 @@ { - "ValueSpecialEpisodeName": "Berezia - {0}", - "Sync": "Sinkronizatu", - "Songs": "Abestiak", "Shows": "Serieak", - "Playlists": "Erreprodukzio-zerrendak", "Photos": "Argazkiak", "MusicVideos": "Bideo musikalak", "Movies": "Filmak", "HeaderContinueWatching": "Ikusten jarraitu", - "HeaderAlbumArtists": "Albumeko artistak", "Genres": "Generoak", "Folders": "Karpetak", "Favorites": "Gogokoak", "Default": "Lehenetsia", "Collections": "Bildumak", - "Channels": "Kanalak", "Books": "Liburuak", "Artists": "Artistak", - "Albums": "Albumak", "TaskOptimizeDatabase": "Datu basea optimizatu", "TaskDownloadMissingSubtitlesDescription": "Falta diren azpitituluak bilatzen ditu interneten metadatuen konfigurazioaren arabera.", "TaskDownloadMissingSubtitles": "Falta diren azpitituluak deskargatu", @@ -44,10 +37,8 @@ "TasksLibraryCategory": "Liburutegia", "TasksMaintenanceCategory": "Mantenua", "VersionNumber": "Bertsioa {0}", - "ValueHasBeenAddedToLibrary": "{0} zure multimedia liburutegian gehitu da", "UserStoppedPlayingItemWithValues": "{0} {1} ikusten bukatu du {2}-(e)n", "UserStartedPlayingItemWithValues": "{0} {1} ikusten ari da {2}-(e)n", - "UserPolicyUpdatedWithName": "{0} erabiltzailearen politikak aldatu dira", "UserPasswordChangedWithName": "{0} erabiltzailearen pasahitza aldatu da", "UserOnlineFromDevice": "{0} online dago {1}-(e)tik", "UserOfflineFromDevice": "{0} {1}-(e)tik deskonektatu da", @@ -55,19 +46,14 @@ "UserDownloadingItemWithValues": "{0} {1} deskargatzen ari da", "UserDeletedWithName": "{0} Erabiltzailea ezabatu da", "UserCreatedWithName": "{0} Erabiltzailea sortu da", - "User": "Erabiltzailea", "Undefined": "Ezezaguna", "TvShows": "TB serieak", - "System": "Sistema", "SubtitleDownloadFailureFromForItem": "{1}-en azpitutuluak {0}-tik deskargatzeak huts egin du", "StartupEmbyServerIsLoading": "Jellyfin zerbitzaria kargatzen. Saiatu berriro beranduago.", - "ServerNameNeedsToBeRestarted": "{0} berrabiarazi behar da", - "ScheduledTaskStartedWithName": "{0} hasi da", "ScheduledTaskFailedWithName": "{0} huts egin du", "PluginUpdatedWithName": "{0} eguneratu da", "PluginUninstalledWithName": "{0} desinstalatu da", "PluginInstalledWithName": "{0} instalatu da", - "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Bideoa geldituta", "NotificationOptionVideoPlayback": "Bideoa martxan", "NotificationOptionUserLockedOut": "Erabiltzailea blokeatua", @@ -90,37 +76,22 @@ "NameInstallFailed": "{0} instalazioak huts egin du", "Music": "Musika", "MixedContent": "Eduki mistoa", - "MessageServerConfigurationUpdated": "Zerbitzariaren konfigurazioa eguneratu da", - "MessageNamedServerConfigurationUpdatedWithValue": "Zerbitzariaren {0} konfigurazio atala eguneratu da", - "MessageApplicationUpdatedTo": "Jellyfin zerbitzaria {0}-ra eguneratu da", - "MessageApplicationUpdated": "Jellyfin zerbitzaria eguneratu da", "Latest": "Azkena", "LabelRunningTimeValue": "Iraupena: {0}", "LabelIpAddressValue": "IP helbidea: {0}", - "ItemRemovedWithName": "{0} liburutegitik kendu da", - "ItemAddedWithName": "{0} liburutegira gehitu da", "HomeVideos": "Etxeko bideoak", "HeaderNextUp": "Hurrengoa", "HeaderLiveTV": "Zuzeneko TB", - "HeaderFavoriteSongs": "Gogoko abestiak", "HeaderFavoriteShows": "Gogoko serieak", "HeaderFavoriteEpisodes": "Gogoko atalak", - "HeaderFavoriteArtists": "Gogoko artistak", - "HeaderFavoriteAlbums": "Gogoko albumak", "Forced": "Behartuta", "FailedLoginAttemptWithUserName": "{0}-tik saioa hasteak huts egin du", "External": "Kanpokoa", - "DeviceOnlineWithName": "{0} konektatu da", - "DeviceOfflineWithName": "{0} deskonektatu da", "ChapterNameValue": "{0} Kapitulua", - "CameraImageUploadedFrom": "{0}-tik kamera irudi berri bat igo da", "AuthenticationSucceededWithUserName": "{0} ongi autentifikatu da", - "Application": "Aplikazioa", "AppDeviceValues": "App: {0}, Gailua: {1}", "HearingImpaired": "Entzumen urritasuna", - "ProviderValue": "Hornitzailea: {0}", "TaskKeyframeExtractorDescription": "Bideo fitxategietako fotograma gakoak ateratzen ditu HLS erreprodukzio-zerrenda zehatzagoak sortzeko. Zeregin honek denbora asko iraun dezake.", - "HeaderRecordingGroups": "Grabaketa taldeak", "Inherit": "Oinordetu", "TaskOptimizeDatabaseDescription": "Datu-basea trinkotu eta bertatik espazioa askatzen du. Liburutegia eskaneatu ondoren edo datu-basean aldaketak egin ondoren ataza hau exekutatzeak errendimendua hobetu lezake.", "TaskKeyframeExtractor": "Fotograma gakoen erauzgailua", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "Lehendik dauden trickplay fitxategiak liburutegiaren ezarpenen arabera mugitzen dira.", "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu.", "CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.", - "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina" + "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina", + "LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik", + "Original": "Jatorrizkoa" } diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 435485db7c..17ed54b117 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -1,41 +1,24 @@ { - "Albums": "آلبومها", "AppDeviceValues": "برنامه: {0} ، دستگاه: {1}", - "Application": "برنامه", "Artists": "هنرمندان", "AuthenticationSucceededWithUserName": "{0} با موفقیت تایید اعتبار شد", "Books": "کتابها", - "CameraImageUploadedFrom": "یک عکس جدید از دوربین ارسال شده است {0}", - "Channels": "کانالها", "ChapterNameValue": "قسمت {0}", "Collections": "مجموعهها", - "DeviceOfflineWithName": "ارتباط {0} قطع شد", - "DeviceOnlineWithName": "{0} متصل شد", "FailedLoginAttemptWithUserName": "تلاش برای ورود از {0} ناموفق بود", "Favorites": "مورد علاقهها", "Folders": "پوشهها", "Genres": "ژانرها", - "HeaderAlbumArtists": "هنرمندان آلبوم", "HeaderContinueWatching": "ادامه تماشا", - "HeaderFavoriteAlbums": "آلبومهای مورد علاقه", - "HeaderFavoriteArtists": "هنرمندان مورد علاقه", "HeaderFavoriteEpisodes": "قسمتهای مورد علاقه", "HeaderFavoriteShows": "سریالهای مورد علاقه", - "HeaderFavoriteSongs": "آهنگهای مورد علاقه", "HeaderLiveTV": "پخش زنده", "HeaderNextUp": "قسمت بعدی", - "HeaderRecordingGroups": "گروههای ضبط", "HomeVideos": "ویدیوهای خانگی", "Inherit": "به ارث برده", - "ItemAddedWithName": "{0} به کتابخانه افزوده شد", - "ItemRemovedWithName": "{0} از کتابخانه حذف شد", "LabelIpAddressValue": "آدرس آی پی: {0}", "LabelRunningTimeValue": "زمان اجرا: {0}", "Latest": "جدیدترینها", - "MessageApplicationUpdated": "سرور Jellyfin بروزرسانی شد", - "MessageApplicationUpdatedTo": "سرور Jellyfin به نسخه {0} بروزرسانی شد", - "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", - "MessageServerConfigurationUpdated": "پیکربندی سرور بروزرسانی شد", "MixedContent": "محتوای مخلوط", "Movies": "فیلم ها", "Music": "موسیقی", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "پخش ویدیو آغاز شد", "NotificationOptionVideoPlaybackStopped": "پخش ویدیو متوقف شد", "Photos": "عکسها", - "Playlists": "لیستهای پخش", - "Plugin": "افزونه", "PluginInstalledWithName": "{0} نصب شد", "PluginUninstalledWithName": "{0} حذف شد", "PluginUpdatedWithName": "{0} آپدیت شد", - "ProviderValue": "ارائه دهنده: {0}", "ScheduledTaskFailedWithName": "{0} شکست خورد", - "ScheduledTaskStartedWithName": "{0} شروع شد", - "ServerNameNeedsToBeRestarted": "{0} نیاز به راه اندازی مجدد دارد", "Shows": "سریالها", - "Songs": "موسیقیها", "StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.", "SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد", - "Sync": "همگامسازی", - "System": "سیستم", "TvShows": "سریالهای تلویزیونی", - "User": "کاربر", "UserCreatedWithName": "کاربر {0} ایجاد شد", "UserDeletedWithName": "کاربر {0} حذف شد", "UserDownloadingItemWithValues": "{0} در حال بارگیری {1} میباشد", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "ارتباط {0} از {1} قطع شد", "UserOnlineFromDevice": "{0} از {1} آنلاین میباشد", "UserPasswordChangedWithName": "گذرواژه برای کاربر {0} تغییر کرد", - "UserPolicyUpdatedWithName": "سیاست کاربری برای {0} بروزرسانی شد", "UserStartedPlayingItemWithValues": "{0} در حال پخش {1} بر روی {2} است", "UserStoppedPlayingItemWithValues": "{0} پخش {1} را بر روی {2} به پایان رساند", - "ValueHasBeenAddedToLibrary": "{0} به کتابخانهی رسانهی شما افزوده شد", - "ValueSpecialEpisodeName": "ویژه - {0}", "VersionNumber": "نسخه {0}", "TaskCleanTranscodeDescription": "فایلهای کدگذاری که قدیمیتر از یک روز هستند را حذف میکند.", "TaskCleanTranscode": "پاکسازی مسیر کد گذاری", diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index d3237db8b0..d080eb0236 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -8,57 +8,33 @@ "Music": "Musiikki", "Movies": "Elokuvat", "MixedContent": "Sekalainen sisältö", - "MessageServerConfigurationUpdated": "Palvelimen asetukset on päivitetty", - "MessageNamedServerConfigurationUpdatedWithValue": "Palvelimen asetusten osio {0} on päivitetty", - "MessageApplicationUpdatedTo": "Jellyfin-palvelin on päivitetty versioon {0}", - "MessageApplicationUpdated": "Jellyfin-palvelin on päivitetty", "Latest": "Viimeisimmät", "LabelRunningTimeValue": "Kesto: {0}", "LabelIpAddressValue": "IP-osoite: {0}", - "ItemRemovedWithName": "{0} poistettiin kirjastosta", - "ItemAddedWithName": "{0} lisättiin kirjastoon", "Inherit": "Peri", "HomeVideos": "Kotivideot", - "HeaderRecordingGroups": "Tallennusryhmät", "HeaderNextUp": "Seuraavaksi", - "HeaderFavoriteSongs": "Suosikkikappaleet", "HeaderFavoriteShows": "Suosikkisarjat", "HeaderFavoriteEpisodes": "Suosikkijaksot", - "HeaderFavoriteArtists": "Suosikkiesittäjät", - "HeaderFavoriteAlbums": "Suosikkialbumit", "HeaderContinueWatching": "Jatka katselua", - "HeaderAlbumArtists": "Albumin esittäjät", "Genres": "Tyylilajit", "Folders": "Kansiot", "Favorites": "Suosikit", "FailedLoginAttemptWithUserName": "Epäonnistunut kirjautumisyritys lähteestä \"{0}\"", - "DeviceOnlineWithName": "{0} on yhdistetty", - "DeviceOfflineWithName": "{0} on katkaissut yhteyden", "Collections": "Kokoelmat", "ChapterNameValue": "Kappale {0}", - "Channels": "Kanavat", - "CameraImageUploadedFrom": "Uusi kameran kuva on sirretty lähteestä {0}", "Books": "Kirjat", "AuthenticationSucceededWithUserName": "{0} todennus onnistunut", "Artists": "Artistit", - "Application": "Sovellus", "AppDeviceValues": "Sovellus: {0}, Laite: {1}", - "Albums": "Albumit", - "User": "Käyttäjä", - "System": "Järjestelmä", "ScheduledTaskFailedWithName": "{0} epäonnistui", "PluginUpdatedWithName": "{0} päivitettiin", "PluginInstalledWithName": "{0} asennettiin", "Photos": "Valokuvat", - "ScheduledTaskStartedWithName": "\"{0}\" käynnistetty", "PluginUninstalledWithName": "{0} poistettiin", - "Playlists": "Soittolistat", "VersionNumber": "Versio {0}", - "ValueSpecialEpisodeName": "Erikoisjakso - {0}", - "ValueHasBeenAddedToLibrary": "\"{0}\" on lisätty mediakirjastoon", "UserStoppedPlayingItemWithValues": "{0} lopetti kohteen \"{1}\" toiston sijainnissa \"{2}\"", "UserStartedPlayingItemWithValues": "{0} toistaa kohdetta \"{1}\" sijainnissa \"{2}\"", - "UserPolicyUpdatedWithName": "Käyttäjän {0} käyttöoikeudet on päivitetty", "UserPasswordChangedWithName": "Käyttäjän {0} salasana on vaihdettu", "UserOnlineFromDevice": "{0} on yhdistänyt sijainnista \"{1}\"", "UserOfflineFromDevice": "{0} on katkaissut yhteyden sijainnista \"{1}\"", @@ -67,14 +43,9 @@ "UserDeletedWithName": "Käyttäjä {0} on poistettu", "UserCreatedWithName": "Käyttäjä {0} on luotu", "TvShows": "Sarjat", - "Sync": "Synkronointi", "SubtitleDownloadFailureFromForItem": "Tekstityksen lataus lähteestä \"{0}\" kohteelle \"{1}\" epäonnistui", "StartupEmbyServerIsLoading": "Jellyfin-palvelin on latautumassa. Yritä hetken kuluttua uudelleen.", - "Songs": "Kappaleet", "Shows": "Sarjat", - "ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen", - "ProviderValue": "Lähde: {0}", - "Plugin": "Lisäosa", "NotificationOptionVideoPlaybackStopped": "Videon toisto lopetettu", "NotificationOptionVideoPlayback": "Videon toisto aloitettu", "NotificationOptionUserLockedOut": "Käyttäjä on lukittu", @@ -135,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/fil.json b/Emby.Server.Implementations/Localization/Core/fil.json index 28c1d2be5e..30d90e9f98 100644 --- a/Emby.Server.Implementations/Localization/Core/fil.json +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -1,10 +1,7 @@ { "VersionNumber": "Bersyon {0}", - "ValueSpecialEpisodeName": "Espesyal - {0}", - "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong librerya ng medya", "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}", "UserStartedPlayingItemWithValues": "Si {0} ay nagpla-play ng {1} sa {2}", - "UserPolicyUpdatedWithName": "Ang user policy ay nai-update para kay {0}", "UserPasswordChangedWithName": "Napalitan na ang password ni {0}", "UserOnlineFromDevice": "Si {0} ay naka-konekta galing sa {1}", "UserOfflineFromDevice": "Si {0} ay na-diskonekta galing sa {1}", @@ -12,23 +9,14 @@ "UserDownloadingItemWithValues": "Nagdadownload si {0} ng {1}", "UserDeletedWithName": "Natanggal na is user {0}", "UserCreatedWithName": "Nagawa na si user {0}", - "User": "User", "TvShows": "Mga Palabas sa Telebisyon", - "System": "Sistema", - "Sync": "Pag-sync", "SubtitleDownloadFailureFromForItem": "Hindi nai-download ang subtitles {0} para sa {1}", "StartupEmbyServerIsLoading": "Naglo-load ang Jellyfin Server. Mangyaring subukan ulit sandali.", - "Songs": "Mga Kanta", "Shows": "Mga Pelikula", - "ServerNameNeedsToBeRestarted": "Kailangan irestart ang {0}", - "ScheduledTaskStartedWithName": "Nagsimula na ang {0}", "ScheduledTaskFailedWithName": "Hindi gumana ang {0}", - "ProviderValue": "Tagapagtustos: {0}", "PluginUpdatedWithName": "Naiupdate na ang {0}", "PluginUninstalledWithName": "Naiuninstall na ang {0}", "PluginInstalledWithName": "Nainstall na ang {0}", - "Plugin": "Plugin", - "Playlists": "Mga Playlist", "Photos": "Mga Larawan", "NotificationOptionVideoPlaybackStopped": "Huminto na ang pelikula", "NotificationOptionVideoPlayback": "Nagsimula na ang pelikula", @@ -54,42 +42,25 @@ "Music": "Mga Kanta", "Movies": "Mga Pelikula", "MixedContent": "Halo-halong content", - "MessageServerConfigurationUpdated": "Naiupdate na ang server configuration", - "MessageNamedServerConfigurationUpdatedWithValue": "Naiupdate na ang server configuration section {0}", - "MessageApplicationUpdatedTo": "Ang bersyon ng Jellyfin Server ay naiupdate sa {0}", - "MessageApplicationUpdated": "Naiupdate na ang Jellyfin Server", "Latest": "Pinakabago", "LabelRunningTimeValue": "Oras: {0}", "LabelIpAddressValue": "IP address: {0}", - "ItemRemovedWithName": "Naitanggal ang {0} sa librerya", - "ItemAddedWithName": "Naidagdag ang {0} sa librerya", "Inherit": "Manahin", - "HeaderRecordingGroups": "Pagtatalang Grupo", "HeaderNextUp": "Susunod", "HeaderLiveTV": "Live TV", - "HeaderFavoriteSongs": "Mga Paboritong Kanta", "HeaderFavoriteShows": "Mga Paboritong Pelikula", "HeaderFavoriteEpisodes": "Mga Paboritong Yugto", - "HeaderFavoriteArtists": "Mga Paboritong Artista", - "HeaderFavoriteAlbums": "Mga Paboritong Album", "HeaderContinueWatching": "Magpatuloy sa Panonood", - "HeaderAlbumArtists": "Mga Artista ng Album", "Genres": "Mga Kategorya", "Folders": "Mga Folder", "Favorites": "Mga Paborito", "FailedLoginAttemptWithUserName": "Maling login galing kay/sa {0}", - "DeviceOnlineWithName": "Nakakonekta si/ang {0}", - "DeviceOfflineWithName": "Nadiskonekta si/ang {0}", "Collections": "Mga Koleksyon", "ChapterNameValue": "Kabanata {0}", - "Channels": "Mga Channel", - "CameraImageUploadedFrom": "May bagong larawan na naupload galing sa/kay {0}", "Books": "Mga Libro", "AuthenticationSucceededWithUserName": "Napatunayan si/ang {0}", "Artists": "Mga Artista", - "Application": "Aplikasyon", "AppDeviceValues": "Aplikasyon: {0}, Aparato: {1}", - "Albums": "Mga Album", "TaskRefreshLibrary": "Suriin and Librerya ng Medya", "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata.", "TaskRefreshChapterImages": "Kunin ang mga larawan ng kabanata", diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 044abc7fa3..4fb9f4329c 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -2,21 +2,15 @@ "Artists": "Listafólk", "Collections": "Søvn", "Default": "Sjálvgildi", - "DeviceOfflineWithName": "{0} hevur slitið sambandið", "External": "Ytri", "Genres": "Greinar", - "Albums": "Album", "AppDeviceValues": "App: {0}, Eind: {1}", - "Application": "Nýtsluskipan", "Books": "Bøkur", - "Channels": "Rásir", "ChapterNameValue": "Kapittul {0}", - "DeviceOnlineWithName": "{0} er sambundið", "Favorites": "Yndis", "Folders": "Mappur", "Forced": "Kravt", "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", "HeaderFavoriteEpisodes": "Yndispartar", - "HeaderFavoriteSongs": "Yndissangir", "LabelIpAddressValue": "IP atsetur: {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index b05e0d10b4..e05cce47b0 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -1,41 +1,24 @@ { - "Albums": "Albums", "AppDeviceValues": "App : {0}, Appareil : {1}", - "Application": "Application", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", - "CameraImageUploadedFrom": "Une nouvelle photo a été téléversée depuis {0}", - "Channels": "Chaînes", "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", - "DeviceOfflineWithName": "{0} s'est déconnecté", - "DeviceOnlineWithName": "{0} est connecté", "FailedLoginAttemptWithUserName": "Tentative de connexion échouée par {0}", "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Artistes de l'album", "HeaderContinueWatching": "Reprendre le visionnement", - "HeaderFavoriteAlbums": "Albums favoris", - "HeaderFavoriteArtists": "Artistes favoris", "HeaderFavoriteEpisodes": "Épisodes favoris", "HeaderFavoriteShows": "Séries favorites", - "HeaderFavoriteSongs": "Chansons favorites", "HeaderLiveTV": "TV en direct", "HeaderNextUp": "À Suivre", - "HeaderRecordingGroups": "Groupes d'enregistrements", "HomeVideos": "Vidéos personnelles", "Inherit": "Hérite", - "ItemAddedWithName": "{0} a été ajouté à la médiathèque", - "ItemRemovedWithName": "{0} a été supprimé de la médiathèque", "LabelIpAddressValue": "Adresse IP : {0}", "LabelRunningTimeValue": "Durée : {0}", "Latest": "Plus récent", - "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour", - "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour vers la version {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour", - "MessageServerConfigurationUpdated": "La configuration du serveur a été mise à jour", "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Lecture vidéo démarrée", "NotificationOptionVideoPlaybackStopped": "Lecture vidéo arrêtée", "Photos": "Photos", - "Playlists": "Listes de lecture", - "Plugin": "Extension", "PluginInstalledWithName": "{0} a été installé", "PluginUninstalledWithName": "{0} a été désinstallé", "PluginUpdatedWithName": "{0} a été mis à jour", - "ProviderValue": "Fournisseur : {0}", "ScheduledTaskFailedWithName": "{0} a échoué", - "ScheduledTaskStartedWithName": "{0} a commencé", - "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", "Shows": "Séries", - "Songs": "Chansons", "StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", - "Sync": "Synchroniser", - "System": "Système", "TvShows": "Séries Télé", - "User": "Utilisateur", "UserCreatedWithName": "L'utilisateur {0} a été créé", "UserDeletedWithName": "L'utilisateur {0} supprimé", "UserDownloadingItemWithValues": "{0} télécharge {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} s'est déconnecté de {1}", "UserOnlineFromDevice": "{0} s'est connecté de {1}", "UserPasswordChangedWithName": "Le mot de passe de utilisateur {0} a été modifié", - "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}", "UserStartedPlayingItemWithValues": "{0} joue {1} sur {2}", "UserStoppedPlayingItemWithValues": "{0} a terminé la lecture de {1} sur {2}", - "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque", - "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", "TasksLibraryCategory": "Médiathèque", "TasksMaintenanceCategory": "Entretien", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay", "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", - "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", + "LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 8937b1d0c9..ceba1dcb41 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -1,41 +1,24 @@ { - "Albums": "Albums", "AppDeviceValues": "Application : {0}, Appareil : {1}", - "Application": "Application", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", - "CameraImageUploadedFrom": "Une photo a été téléchargée depuis {0}", - "Channels": "Chaînes", "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", - "DeviceOfflineWithName": "{0} s'est déconnecté", - "DeviceOnlineWithName": "{0} est connecté", "FailedLoginAttemptWithUserName": "Échec de connexion depuis {0}", "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Artistes d'albums", "HeaderContinueWatching": "Continuer de regarder", - "HeaderFavoriteAlbums": "Albums favoris", - "HeaderFavoriteArtists": "Artistes préférés", "HeaderFavoriteEpisodes": "Épisodes favoris", "HeaderFavoriteShows": "Séries favorites", - "HeaderFavoriteSongs": "Chansons préférées", "HeaderLiveTV": "TV en direct", "HeaderNextUp": "Prochain à venir", - "HeaderRecordingGroups": "Groupes d'enregistrements", "HomeVideos": "Vidéos personnelles", "Inherit": "Hériter", - "ItemAddedWithName": "{0} a été ajouté à la médiathèque", - "ItemRemovedWithName": "{0} a été supprimé de la médiathèque", "LabelIpAddressValue": "Adresse IP : {0}", "LabelRunningTimeValue": "Durée : {0}", "Latest": "Derniers", - "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour", - "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour en version {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour", - "MessageServerConfigurationUpdated": "La configuration du serveur a été mise à jour", "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Lecture vidéo démarrée", "NotificationOptionVideoPlaybackStopped": "Lecture vidéo arrêtée", "Photos": "Photos", - "Playlists": "Listes de lecture", - "Plugin": "Extension", "PluginInstalledWithName": "{0} a été installé", "PluginUninstalledWithName": "{0} a été désinstallé", "PluginUpdatedWithName": "{0} a été mis à jour", - "ProviderValue": "Fournisseur : {0}", "ScheduledTaskFailedWithName": "{0} a échoué", - "ScheduledTaskStartedWithName": "{0} a démarré", - "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", "Shows": "Séries", - "Songs": "Chansons", "StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", - "Sync": "Synchroniser", - "System": "Système", "TvShows": "Séries TV", - "User": "Utilisateur", "UserCreatedWithName": "L'utilisateur {0} a été créé", "UserDeletedWithName": "L'utilisateur {0} a été supprimé", "UserDownloadingItemWithValues": "{0} est en train de télécharger {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} s'est déconnecté depuis {1}", "UserOnlineFromDevice": "{0} s'est connecté depuis {1}", "UserPasswordChangedWithName": "Le mot de passe pour l'utilisateur {0} a été modifié", - "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}", "UserStartedPlayingItemWithValues": "{0} est en train de lire {1} sur {2}", "UserStoppedPlayingItemWithValues": "{0} vient d'arrêter la lecture de {1} sur {2}", - "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque", - "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", "TasksChannelsCategory": "Chaînes en ligne", "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquants sur Internet en se basant sur la configuration des métadonnées.", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.", "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", - "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", + "LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 5742e6224d..1ee606cc64 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -1,15 +1,10 @@ { - "Albums": "Albaim", "Artists": "Ealaíontóirí", "AuthenticationSucceededWithUserName": "D'éirigh le fíordheimhniú {0}", "Books": "Leabhair", - "CameraImageUploadedFrom": "Uaslódáladh íomhá ceamara nua ó {0}", - "Channels": "Cainéil", "ChapterNameValue": "Caibidil {0}", "Collections": "Bailiúcháin", "Default": "Réamhshocrú", - "DeviceOfflineWithName": "Tá {0} dícheangailte", - "DeviceOnlineWithName": "Tá {0} nasctha", "External": "Seachtrach", "FailedLoginAttemptWithUserName": "Theip ar iarracht logáil isteach ó {0}", "Favorites": "Ceanáin", @@ -36,32 +31,20 @@ "TaskOptimizeDatabaseDescription": "Comhdhlúthaíonn bunachar sonraí agus gearrtar spás saor in aisce. Má ritheann tú an tasc seo tar éis scanadh a dhéanamh ar an leabharlann nó athruithe eile a dhéanamh a thugann le tuiscint gur cheart go bhfeabhsófaí an fheidhmíocht.", "TaskMoveTrickplayImagesDescription": "Bogtar comhaid trickplay atá ann cheana de réir socruithe na leabharlainne.", "AppDeviceValues": "Aip: {0}, Gléas: {1}", - "Application": "Feidhmchlár", "Folders": "Fillteáin", "Forced": "Éigean", "Genres": "Seánraí", - "HeaderAlbumArtists": "Ealaíontóirí albam", "HeaderContinueWatching": "Leanúint ar aghaidh ag Breathnú", - "HeaderFavoriteAlbums": "Albam is fearr leat", - "HeaderFavoriteArtists": "Ealaíontóirí is Fearr", "HeaderFavoriteEpisodes": "Eipeasóid is fearr leat", "HeaderFavoriteShows": "Seónna is Fearr", - "HeaderFavoriteSongs": "Amhráin is fearr leat", "HeaderLiveTV": "Teilifís beo", "HeaderNextUp": "Ar Aghaidh Suas", - "HeaderRecordingGroups": "Grúpaí Taifeadta", "HearingImpaired": "Lag éisteachta", "HomeVideos": "Físeáin Baile", "Inherit": "Oidhreacht", - "ItemAddedWithName": "Cuireadh {0} leis an leabharlann", - "ItemRemovedWithName": "Baineadh {0} den leabharlann", "LabelIpAddressValue": "Seoladh IP: {0}", "LabelRunningTimeValue": "Am rite: {0}", "Latest": "Is déanaí", - "MessageApplicationUpdated": "Tá Freastalaí Jellyfin nuashonraithe", - "MessageApplicationUpdatedTo": "Nuashonraíodh Freastalaí Jellyfin go {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Nuashonraíodh an chuid cumraíochta freastalaí {0}", - "MessageServerConfigurationUpdated": "Nuashonraíodh cumraíocht an fhreastalaí", "MixedContent": "Ábhar measctha", "Movies": "Scannáin", "Music": "Ceol", @@ -87,24 +70,15 @@ "NotificationOptionVideoPlayback": "Cuireadh tús le hathsheinm físe", "NotificationOptionVideoPlaybackStopped": "Cuireadh deireadh le hathsheinm físe", "Photos": "Grianghraif", - "Playlists": "Seinmliostaí", - "Plugin": "Breiseán", "PluginInstalledWithName": "Suiteáladh {0}", "PluginUninstalledWithName": "Díshuiteáladh {0}", "PluginUpdatedWithName": "Nuashonraíodh {0}", - "ProviderValue": "Soláthraí: {0}", "ScheduledTaskFailedWithName": "Theip ar {0}", - "ScheduledTaskStartedWithName": "Thosaigh {0}", - "ServerNameNeedsToBeRestarted": "Ní mór {0} a atosú", "Shows": "Seónna", - "Songs": "Amhráin", "StartupEmbyServerIsLoading": "Tá freastalaí Jellyfin á luchtú. Bain triail eile as gan mhoill.", "SubtitleDownloadFailureFromForItem": "Theip ar fhotheidil a íoslódáil ó {0} le haghaidh {1}", - "Sync": "Sioncrónaigh", - "System": "Córas", "TvShows": "Seónna Teilifíse", "Undefined": "Neamhshainithe", - "User": "Úsáideoir", "UserCreatedWithName": "Cruthaíodh úsáideoir {0}", "UserDeletedWithName": "Scriosadh úsáideoir {0}", "UserDownloadingItemWithValues": "Tá {0} á íoslódáil {1}", @@ -112,11 +86,8 @@ "UserOfflineFromDevice": "Tá {0} dícheangailte ó {1}", "UserOnlineFromDevice": "Tá {0} ar líne ó {1}", "UserPasswordChangedWithName": "Athraíodh pasfhocal don úsáideoir {0}", - "UserPolicyUpdatedWithName": "Nuashonraíodh polasaí úsáideora le haghaidh {0}", "UserStartedPlayingItemWithValues": "Tá {0} ag seinnt {1} ar {2}", "UserStoppedPlayingItemWithValues": "Chríochnaigh {0} ag imirt {1} ar {2}", - "ValueHasBeenAddedToLibrary": "Cuireadh {0} le do leabharlann meán", - "ValueSpecialEpisodeName": "Speisialta - {0}", "VersionNumber": "Leagan {0}", "TasksMaintenanceCategory": "Cothabháil", "TasksLibraryCategory": "Leabharlann", @@ -135,5 +106,7 @@ "TaskCleanTranscode": "Eolaire Transcode Glan", "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." + "CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.", + "Original": "Bunaidh", + "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index fc5c3fd53d..a68db0076a 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -1,13 +1,9 @@ { - "Albums": "Álbums", "Collections": "Coleccións", "ChapterNameValue": "Capítulo {0}", - "Channels": "Canles", - "CameraImageUploadedFrom": "Cargouse unha nova imaxe de cámara dende {0}", "Books": "Libros", "AuthenticationSucceededWithUserName": "{0} autenticouse correctamente", "Artists": "Artistas", - "Application": "Aplicación", "NotificationOptionServerRestartRequired": "Necesario o reinicio do servidor", "NotificationOptionPluginUpdateInstalled": "Actualización do plugin instalada", "NotificationOptionPluginUninstalled": "Plugin desinstalado", @@ -28,63 +24,41 @@ "Music": "Música", "Movies": "Películas", "MixedContent": "Contido mixto", - "MessageServerConfigurationUpdated": "Actualizouse a configuración do servidor", - "MessageNamedServerConfigurationUpdatedWithValue": "Actualizouse a sección de configuración {0} do servidor", - "MessageApplicationUpdatedTo": "O servidor Jellyfin actualizouse a {0}", - "MessageApplicationUpdated": "O servidor Jellyfin actualizouse", "Latest": "Último", "LabelRunningTimeValue": "Tempo en execución: {0}", "LabelIpAddressValue": "Enderezo IP: {0}", - "ItemRemovedWithName": "{0} eliminouse da biblioteca", - "ItemAddedWithName": "{0} engadiuse á biblioteca", "Inherit": "Herdar", "HomeVideos": "Videos caseiros", - "HeaderRecordingGroups": "Grupos de grabación", "HeaderNextUp": "De seguido", "HeaderLiveTV": "TV en directo", - "HeaderFavoriteSongs": "Cancións favoritas", "HeaderFavoriteShows": "Series de TV favoritas", "HeaderFavoriteEpisodes": "Episodios favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", - "HeaderFavoriteAlbums": "Álbums favoritos", "HeaderContinueWatching": "Seguir vendo", - "HeaderAlbumArtists": "Artistas do álbum", "Genres": "Xéneros", "Forced": "Forzado", "Folders": "Cartafoles", "Favorites": "Favoritos", "FailedLoginAttemptWithUserName": "Fallo de intento de inicio de sesión dende {0}", - "DeviceOnlineWithName": "{0} conectouse", - "DeviceOfflineWithName": "{0} desconectouse", "Default": "Por defecto", "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", "TaskCleanLogs": "Limpar directorio de rexistros", "TaskCleanActivityLog": "Limpar rexistro de actividade", "TasksChannelsCategory": "Canles da Internet", "TaskUpdatePlugins": "Actualizar plugins", - "User": "Usuario", "Undefined": "Sen definir", "TvShows": "Programas de TV", - "System": "Sistema", - "Sync": "Sincronizar", "SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}", "StartupEmbyServerIsLoading": "O servidor Jellyfin está cargando. Por favor, ténteo axiña outra vez.", - "Songs": "Cancións", "Shows": "Programas", - "ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado", - "ScheduledTaskStartedWithName": "{0} comezou", "ScheduledTaskFailedWithName": "{0} fallou", - "ProviderValue": "Provedor: {0}", "PluginUpdatedWithName": "{0} foi actualizado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginInstalledWithName": "{0} foi instalado", - "Playlists": "Listas de reproducción", "Photos": "Fotos", "UserLockedOutWithName": "O usuario {0} foi bloqueado", "UserDownloadingItemWithValues": "{0} está a ser transferido {1}", "UserDeletedWithName": "O usuario {0} foi borrado", "UserCreatedWithName": "O usuario {0} foi creado", - "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo detida", "NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada", "NotificationOptionUserLockedOut": "Usuario bloqueado", @@ -109,12 +83,9 @@ "TaskCleanCache": "Limpar directorio de caché", "TaskCleanActivityLogDescription": "Borra do rexistro de actividade as entradas anteriores á data configurada.", "TasksApplicationCategory": "Aplicación", - "ValueSpecialEpisodeName": "Especial - {0}", - "ValueHasBeenAddedToLibrary": "{0} engadiuse á túa biblioteca de medios", "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Mantemento", "VersionNumber": "Versión {0}", - "UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}", "UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}", "UserOnlineFromDevice": "{0} está en liña desde {1}", "UserOfflineFromDevice": "{0} desconectouse dende {1}", @@ -135,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/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index 9be6f05ee1..b95e5edecc 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -1,41 +1,24 @@ { - "Albums": "Alben", "AppDeviceValues": "App: {0}, Gerät: {1}", - "Application": "Applikation", "Artists": "Künstler", "AuthenticationSucceededWithUserName": "{0} hat sich angemeldet", "Books": "Bücher", - "CameraImageUploadedFrom": "Ein neues Foto wurde von {0} hochgeladen", - "Channels": "Kanäle", "ChapterNameValue": "Kapitel {0}", "Collections": "Sammlungen", - "DeviceOfflineWithName": "{0} wurde getrennt", - "DeviceOnlineWithName": "{0} ist verbunden", "FailedLoginAttemptWithUserName": "Fählgschlagene Ameldeversuech vo {0}", "Favorites": "Favorite", "Folders": "Ordner", "Genres": "Genre", - "HeaderAlbumArtists": "Album-Künschtler", "HeaderContinueWatching": "weiter schauen", - "HeaderFavoriteAlbums": "Lieblingsalben", - "HeaderFavoriteArtists": "Lieblings-Künstler", "HeaderFavoriteEpisodes": "Lieblingsepisoden", "HeaderFavoriteShows": "Lieblingsserien", - "HeaderFavoriteSongs": "Lieblingslieder", "HeaderLiveTV": "Live-Fernseh", "HeaderNextUp": "Als Nächstes", - "HeaderRecordingGroups": "Aufnahme-Gruppen", "HomeVideos": "Heimvideos", "Inherit": "Vererben", - "ItemAddedWithName": "{0} wurde der Bibliothek hinzugefügt", - "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt", "LabelIpAddressValue": "IP-Adresse: {0}", "LabelRunningTimeValue": "Laufzeit: {0}", "Latest": "Neueste", - "MessageApplicationUpdated": "Jellyfin-Server wurde aktualisiert", - "MessageApplicationUpdatedTo": "Jellyfin-Server wurde auf Version {0} aktualisiert", - "MessageNamedServerConfigurationUpdatedWithValue": "Der Server-Einstellungsbereich {0} wurde aktualisiert", - "MessageServerConfigurationUpdated": "Serveriistöuige send aktualisiert worde", "MixedContent": "Gmeschti Inhäut", "Movies": "Film", "Music": "Musig", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Videowedergab gstartet", "NotificationOptionVideoPlaybackStopped": "Videowedergab gstoppt", "Photos": "Fotis", - "Playlists": "Wedergabeliste", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} esch installiert worde", "PluginUninstalledWithName": "{0} esch deinstalliert worde", "PluginUpdatedWithName": "{0} esch updated worde", - "ProviderValue": "Aabieter: {0}", "ScheduledTaskFailedWithName": "{0} esch fäugschlage", - "ScheduledTaskStartedWithName": "{0} het gstartet", - "ServerNameNeedsToBeRestarted": "{0} mues nöi gstartet wärde", "Shows": "Serie", - "Songs": "Lieder", "StartupEmbyServerIsLoading": "Jellyfin Server ladt. Bitte grad noeinisch probiere.", "SubtitleDownloadFailureFromForItem": "Ondertetle vo {0} för {1} hend ned chönne abeglade wärde", - "Sync": "Synchronisation", - "System": "System", "TvShows": "Färnsehserie", - "User": "Benotzer", "UserCreatedWithName": "Benotzer {0} esch erstöut worde", "UserDeletedWithName": "Benotzer {0} esch glösche worde", "UserDownloadingItemWithValues": "{0} ladt {1} abe", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} esch vo {1} trennt worde", "UserOnlineFromDevice": "{0} esch online vo {1}", "UserPasswordChangedWithName": "S'Passwort för Benotzer {0} esch gänderet worde", - "UserPolicyUpdatedWithName": "Benotzerrechtlinie för {0} esch aktualisiert worde", "UserStartedPlayingItemWithValues": "{0} hed d'Wedergab vo {1} of {2} gstartet", "UserStoppedPlayingItemWithValues": "{0} het d'Wedergab vo {1} of {2} gstoppt", - "ValueHasBeenAddedToLibrary": "{0} esch dinnere Biblithek hinzuegfüegt worde", - "ValueSpecialEpisodeName": "Extra - {0}", "VersionNumber": "Version {0}", "TaskCleanLogs": "Lösche Log Pfad", "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.", diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 606f464503..48056b0bb8 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -1,41 +1,24 @@ { - "Albums": "אלבומים", "AppDeviceValues": "יישום: {0}, מכשיר: {1}", - "Application": "יישום", "Artists": "אומנים", "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", "Books": "ספרים", - "CameraImageUploadedFrom": "תמונת מצלמה חדשה הועלתה מתוך {0}", - "Channels": "ערוצים", "ChapterNameValue": "פרק {0}", "Collections": "אוספים", - "DeviceOfflineWithName": "{0} התנתק", - "DeviceOnlineWithName": "{0} מחובר", "FailedLoginAttemptWithUserName": "ניסיון כניסה שגוי דרך {0}", "Favorites": "מועדפים", "Folders": "תיקיות", "Genres": "ז׳אנרים", - "HeaderAlbumArtists": "אמני האלבום", "HeaderContinueWatching": "המשך צפייה", - "HeaderFavoriteAlbums": "אלבומים מועדפים", - "HeaderFavoriteArtists": "אמנים מועדפים", "HeaderFavoriteEpisodes": "פרקים מועדפים", "HeaderFavoriteShows": "תוכניות מועדפות", - "HeaderFavoriteSongs": "שירים מועדפים", "HeaderLiveTV": "שידורים חיים", "HeaderNextUp": "הבא בתור", - "HeaderRecordingGroups": "קבוצות הקלטה", "HomeVideos": "סרטונים בייתים", "Inherit": "הורש", - "ItemAddedWithName": "{0} נוסף לספרייה", - "ItemRemovedWithName": "{0} נמחק מהספרייה", "LabelIpAddressValue": "Ip כתובת: {0}", "LabelRunningTimeValue": "משך צפייה: {0}", "Latest": "אחרון", - "MessageApplicationUpdated": "שרת Jellyfin עודכן", - "MessageApplicationUpdatedTo": "שרת Jellyfin עודכן לגרסה {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "סעיף הגדרת השרת {0} עודכן", - "MessageServerConfigurationUpdated": "תצורת השרת עודכנה", "MixedContent": "תוכן מעורב", "Movies": "סרטים", "Music": "מוזיקה", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "ניגון וידאו החל", "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק", "Photos": "צילומים", - "Playlists": "רשימות נגינה", - "Plugin": "תוסף", "PluginInstalledWithName": "{0} הותקן", "PluginUninstalledWithName": "{0} הוסר", "PluginUpdatedWithName": "{0} עודכן", - "ProviderValue": "ספק: {0}", "ScheduledTaskFailedWithName": "{0} נכשל", - "ScheduledTaskStartedWithName": "{0} החל", - "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש", "Shows": "סדרות", - "Songs": "שירים", "StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.", "SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה", - "Sync": "סנכרון", - "System": "מערכת", "TvShows": "סדרות טלוויזיה", - "User": "משתמש", "UserCreatedWithName": "המשתמש {0} נוצר", "UserDeletedWithName": "המשתמש {0} הוסר", "UserDownloadingItemWithValues": "{0} מוריד את {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} התנתק מ־{1}", "UserOnlineFromDevice": "{0} מחובר מ־{1}", "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", - "UserPolicyUpdatedWithName": "מדיניות המשתמש {0} עודכנה", "UserStartedPlayingItemWithValues": "{0} מנגן את {1} על {2}", "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} על {2}", - "ValueHasBeenAddedToLibrary": "{0} התווסף לספריית המדיה שלך", - "ValueSpecialEpisodeName": "מיוחד- {0}", "VersionNumber": "גרסה {0}", "TaskRefreshLibrary": "סרוק ספריית מדיה", "TaskRefreshChapterImages": "חלץ תמונות פרקים", @@ -135,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 e8812c8a1d..b551608fd0 100644 --- a/Emby.Server.Implementations/Localization/Core/he_IL.json +++ b/Emby.Server.Implementations/Localization/Core/he_IL.json @@ -1,27 +1,112 @@ { "Books": "ספרים", "NameSeasonNumber": "עונה {0}", - "Channels": "ערוצים", "Movies": "סרטים", "Music": "מוזיקה", "Collections": "אוספים", - "Albums": "אלבומים", - "Application": "אפליקציה", "Artists": "אמנים", "ChapterNameValue": "פרק {0}", "External": "חיצונית", "Favorites": "מועדפים", "Folders": "תיקיות", "Genres": "ז'אנרים", - "HeaderAlbumArtists": "אמני אלבומים", "HeaderContinueWatching": "להמשיך לצפות", - "HeaderFavoriteAlbums": "אלבומים אהובים", - "HeaderFavoriteArtists": "אמנים אהובים", "HeaderFavoriteEpisodes": "פרקים אהובים", "HeaderFavoriteShows": "תוכניות אהובות", - "HeaderFavoriteSongs": "שירים אהובים", "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/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 9968c56b21..e98a5fbac1 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -1,31 +1,20 @@ { - "Albums": "एल्बम", - "HeaderRecordingGroups": "रिकॉर्डिंग समूह", "HeaderNextUp": "इसके बाद", "HeaderLiveTV": "लाइव टीवी", - "HeaderFavoriteSongs": "पसंदीदा गीत", "HeaderFavoriteShows": "पसंदीदा शो", "HeaderFavoriteEpisodes": "पसंदीदा प्रकरण", - "HeaderFavoriteArtists": "पसंदीदा कलाकार", - "HeaderFavoriteAlbums": "पसंदीदा एलबम्स", "HeaderContinueWatching": "देखना जारी रखें", - "HeaderAlbumArtists": "एल्बम कलाकार", "Genres": "शैलियां", "Forced": "बलपूर्वक", "Folders": "फ़ोल्डर", "Favorites": "पसंदीदा", "FailedLoginAttemptWithUserName": "{0} से संप्रवेश असफल हुआ", - "DeviceOnlineWithName": "{0} कनेक्ट हो गया है", - "DeviceOfflineWithName": "{0} डिस्कनेक्ट हो गया है", "Default": "प्राथमिक", "Collections": "संग्रह", "ChapterNameValue": "अध्याय {0}", - "Channels": "चैनल", - "CameraImageUploadedFrom": "{0} से एक नया कैमरा छवि अपलोड की गई है", "Books": "पुस्तकें", "AuthenticationSucceededWithUserName": "{0} सफलतापूर्वक प्रमाणित किया गया", "Artists": "कलाकार", - "Application": "एप्लिकेशन", "AppDeviceValues": "एप: {0}, उपकरण: {1}", "NotificationOptionPluginUninstalled": "प्लगइन अनइंस्टाल हो गया", "NotificationOptionPluginInstalled": "प्लगइन इनस्टॉल हो गया", @@ -44,13 +33,8 @@ "Music": "संगीत", "Movies": "फ़िल्म", "MixedContent": "मिला-जुला कंटेंट", - "MessageServerConfigurationUpdated": "सर्वर कॉन्फ़िगरेशन अपडेट हो गया है", - "MessageNamedServerConfigurationUpdatedWithValue": "सर्वर कॉन्फ़िगरेशन भाग {0} अपडेट हो गया है", - "MessageApplicationUpdatedTo": "जैलीफिन सर्वर {0} में अपडेट हो गया है", - "MessageApplicationUpdated": "जैलीफिन सर्वर अपडेट हो गया है", "Latest": "सबसे नया", "LabelIpAddressValue": "आई पी एड्रेस: {0}", - "ItemRemovedWithName": "{0} लाइब्रेरी में से निकाल दिया है", "HomeVideos": "होम चलचित्र", "NotificationOptionVideoPlayback": "वीडियो प्लेबैक शुरू हुआ", "NotificationOptionUserLockedOut": "उपयोगकर्ता लॉक हो गया", @@ -59,22 +43,16 @@ "NotificationOptionPluginUpdateInstalled": "प्लगइन अद्यतन स्थापित", "NotificationOptionNewLibraryContent": "नई सामग्री जोड़ी गई", "LabelRunningTimeValue": "चलने का समय: {0}", - "ItemAddedWithName": "{0} को लाइब्रेरी में जोड़ा गया", "Inherit": "इनहेरिट", "NotificationOptionVideoPlaybackStopped": "चलचित्र रुका हुआ", "PluginUninstalledWithName": "{0} अनइंस्टॉल हुए", "PluginInstalledWithName": "{0} इंस्टॉल हुए", - "Plugin": "प्लग-इन", - "Playlists": "प्लेलिस्ट", "Photos": "तस्वीरें", "External": "बाहरी", "PluginUpdatedWithName": "{0} अपडेट हुए", - "ScheduledTaskStartedWithName": "{0} शुरू हुए", - "Songs": "गाने", "UserStartedPlayingItemWithValues": "{0} {2} पर {1} खेल रहे हैं", "UserStoppedPlayingItemWithValues": "{0} ने {2} पर {1} खेलना खत्म किया", "StartupEmbyServerIsLoading": "जेलीफ़िन सर्वर लोड हो रहा है। कृपया शीघ्र ही पुन: प्रयास करें।", - "ServerNameNeedsToBeRestarted": "{0} रीस्टार्ट करने की आवश्यकता है", "UserCreatedWithName": "उपयोगकर्ता {0} बनाया गया", "UserDownloadingItemWithValues": "{0} डाउनलोड हो रहा है", "UserOfflineFromDevice": "{0} {1} से डिस्कनेक्ट हो गया है", @@ -83,20 +61,13 @@ "Shows": "शो", "UserPasswordChangedWithName": "उपयोगकर्ता {0} के लिए पासवर्ड बदल दिया गया है", "UserDeletedWithName": "उपयोगकर्ता {0} हटा दिया गया", - "UserPolicyUpdatedWithName": "{0} के लिए उपयोगकर्ता नीति अपडेट कर दी गई है", - "User": "उपयोगकर्ता", "SubtitleDownloadFailureFromForItem": "{1} के लिए {0} से उपशीर्षक डाउनलोड करने में विफल", - "ProviderValue": "प्रदाता: {0}", "ScheduledTaskFailedWithName": "{0}असफल", "UserLockedOutWithName": "उपयोगकर्ता {0} को लॉक आउट कर दिया गया है", - "System": "प्रणाली", "TvShows": "टीवी शो", "HearingImpaired": "मूक बधिर", - "ValueSpecialEpisodeName": "विशेष - {0}", "TasksMaintenanceCategory": "रखरखाव", - "Sync": "समाकलयति", "VersionNumber": "{0} पाठान्तर", - "ValueHasBeenAddedToLibrary": "{0} आपके माध्यम ग्रन्थालय में उपजात हो गया हैं", "TasksLibraryCategory": "संग्रहालय", "TaskOptimizeDatabase": "जानकारी प्रवृद्धि", "TaskDownloadMissingSubtitles": "लापता अनुलेख डाउनलोड करें", diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index e3bea78a3f..8794339fb1 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -1,41 +1,24 @@ { - "Albums": "Albumi", "AppDeviceValues": "Aplikacija: {0}, Uređaj: {1}", - "Application": "Aplikacija", "Artists": "Izvođači", "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}", - "Channels": "Kanali", "ChapterNameValue": "Poglavlje {0}", "Collections": "Zbirke", - "DeviceOfflineWithName": "{0} je prekinuo vezu", - "DeviceOnlineWithName": "{0} je povezan", "FailedLoginAttemptWithUserName": "Neuspješan pokušaj prijave od {0}", "Favorites": "Favoriti", "Folders": "Mape", "Genres": "Žanrovi", - "HeaderAlbumArtists": "Izvođači albuma", "HeaderContinueWatching": "Nastavi gledati", - "HeaderFavoriteAlbums": "Omiljeni albumi", - "HeaderFavoriteArtists": "Omiljeni izvođači", "HeaderFavoriteEpisodes": "Omiljene epizode", "HeaderFavoriteShows": "Omiljene serije", - "HeaderFavoriteSongs": "Omiljene pjesme", "HeaderLiveTV": "TV uživo", "HeaderNextUp": "Sljedeće na redu", - "HeaderRecordingGroups": "Grupa snimka", "HomeVideos": "Kućni video", "Inherit": "Naslijedi", - "ItemAddedWithName": "{0} je dodano u biblioteku", - "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Vrijeme rada: {0}", "Latest": "Najnovije", - "MessageApplicationUpdated": "Jellyfin server je ažuriran", - "MessageApplicationUpdatedTo": "Jellyfin server je ažuriran na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Dio konfiguracije servera {0} je ažuriran", - "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana", "MixedContent": "Miješani sadržaj", "Movies": "Filmovi", "Music": "Glazba", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Reprodukcija videa započela", "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa zaustavljena", "Photos": "Fotografije", - "Playlists": "Popisi za reprodukciju", - "Plugin": "Dodatak", "PluginInstalledWithName": "{0} je instalirano", "PluginUninstalledWithName": "{0} je deinstalirano", "PluginUpdatedWithName": "{0} je ažurirano", - "ProviderValue": "Pružatelj: {0}", "ScheduledTaskFailedWithName": "{0} neuspjelo", - "ScheduledTaskStartedWithName": "{0} pokrenuto", - "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti", "Shows": "Emisije", - "Songs": "Pjesme", "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.", "SubtitleDownloadFailureFromForItem": "Titlovi nisu uspješno preuzeti od {0} za {1}", - "Sync": "Sinkronizacija", - "System": "Sustav", "TvShows": "TV emisije", - "User": "Korisnik", "UserCreatedWithName": "Korisnik {0} je kreiran", "UserDeletedWithName": "Korisnik {0} je obrisan", "UserDownloadingItemWithValues": "{0} preuzima {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} prekinuo vezu od {1}", "UserOnlineFromDevice": "{0} povezan od {1}", "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}", - "UserPolicyUpdatedWithName": "Pravila za korisnika ažurirana su za {0}", "UserStartedPlayingItemWithValues": "{0} je pokrenuo reprodukciju {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je završio reprodukciju {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} je dodano u biblioteku medija", - "ValueSpecialEpisodeName": "Posebno – {0}", "VersionNumber": "Verzija {0}", "TaskRefreshLibraryDescription": "Skenira biblioteku medija radi novih datoteka i osvježava metapodatke.", "TaskRefreshLibrary": "Skeniraj biblioteku medija", @@ -135,5 +106,6 @@ "TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja", "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." + "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/ht.json b/Emby.Server.Implementations/Localization/Core/ht.json index 183c422a85..da90a92339 100644 --- a/Emby.Server.Implementations/Localization/Core/ht.json +++ b/Emby.Server.Implementations/Localization/Core/ht.json @@ -1,33 +1,22 @@ { "Books": "Liv", "TasksLibraryCategory": "Libreri", - "Albums": "Albòm yo", "Artists": "Atis yo", - "Application": "Aplikasyon", - "Channels": "Kanal yo", "ChapterNameValue": "Chapit {0}", "Default": "Defo", - "DeviceOnlineWithName": "{0} konekte", - "DeviceOfflineWithName": "{0} dekonekte", "External": "Extèn", "Collections": "Koleksyon yo", "Favorites": "Pi Renmen", "Folders": "Dosye", "Genres": "Jan yo", "Forced": "Fòse", - "HeaderAlbumArtists": "Albòm Atis", "HeaderContinueWatching": "Kontinye Kade", - "HeaderFavoriteAlbums": "Albòm Pi Renmen", - "HeaderFavoriteArtists": "Atis Pi Renmen", "HeaderFavoriteEpisodes": "Epizòd Pi Renmen", "HeaderFavoriteShows": "Emisyon Pi Renmen", - "HeaderFavoriteSongs": "Mizik Pi Renmen", "HeaderLiveTV": "Televizyon an Direk", "HeaderNextUp": "Pwochen an", "HomeVideos": "Videyo Lakay", "Latest": "Pi Resan", - "MessageApplicationUpdated": "Sèvè Jellyfin met a jou", - "MessageApplicationUpdatedTo": "Sèvè Jellyfin met a jou sou {0}", "Movies": "Fim", "MixedContent": "Kontni Melanje", "Music": "Mizik", @@ -42,12 +31,8 @@ "PluginUninstalledWithName": "{0} te dezenstale", "PluginUpdatedWithName": "{0} te mi a jou", "ScheduledTaskFailedWithName": "{0} echwe", - "ScheduledTaskStartedWithName": "{0} komanse", - "Songs": "Mizik yo", "Shows": "Emisyon yo", - "System": "Sistèm", "TvShows": "Emisyon Tele", - "User": "Itilizatè", "UserCreatedWithName": "Itilizatè {0} kreye", "UserDeletedWithName": "Itilizatè {0} a efase", "UserDownloadingItemWithValues": "{0} ap telechaje {1}", @@ -55,11 +40,10 @@ "UserStartedPlayingItemWithValues": "{0} ap jwe {1} sou {2}", "UserStoppedPlayingItemWithValues": "{0} fin jwe {1} sou {2}", "UserPasswordChangedWithName": "Modpas la chanje pou Itilizatè {0}", - "ValueSpecialEpisodeName": "Spesyal - {0}", "VersionNumber": "Vesyon {0}", "TasksApplicationCategory": "Aplikasyon", "TasksMaintenanceCategory": "Antretyen", "AppDeviceValues": "Aplikasyon: {0}, Aparèy: {1}", "AuthenticationSucceededWithUserName": "{0} otantifye avèk siksè", - "CameraImageUploadedFrom": "Une nouvelle image de la caméra a été téléchargée depuis {0}" + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 8d9e5b08ba..1995d7a4cf 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -1,45 +1,28 @@ { - "Albums": "Albumok", - "AppDeviceValues": "Program: {0}, Eszköz: {1}", - "Application": "Alkalmazás", + "AppDeviceValues": "alkalmazás: {0}, eszköz: {1}", "Artists": "Előadók", "AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve", "Books": "Könyvek", - "CameraImageUploadedFrom": "Új kamerakép lett feltöltve innen: {0}", - "Channels": "Csatornák", "ChapterNameValue": "{0}. jelenet", "Collections": "Gyűjtemények", - "DeviceOfflineWithName": "{0} kijelentkezett", - "DeviceOnlineWithName": "{0} belépett", "FailedLoginAttemptWithUserName": "Sikertelen bejelentkezési kísérlet innen: {0}", "Favorites": "Kedvencek", "Folders": "Mappák", "Genres": "Műfajok", - "HeaderAlbumArtists": "Albumelőadók", "HeaderContinueWatching": "Megtekintés folytatása", - "HeaderFavoriteAlbums": "Kedvenc albumok", - "HeaderFavoriteArtists": "Kedvenc előadók", "HeaderFavoriteEpisodes": "Kedvenc epizódok", "HeaderFavoriteShows": "Kedvenc sorozatok", - "HeaderFavoriteSongs": "Kedvenc számok", "HeaderLiveTV": "Élő TV", "HeaderNextUp": "Következik", - "HeaderRecordingGroups": "Felvételi csoportok", "HomeVideos": "Otthoni videók", "Inherit": "Öröklés", - "ItemAddedWithName": "{0} hozzáadva a médiatárhoz", - "ItemRemovedWithName": "{0} eltávolítva a médiatárból", "LabelIpAddressValue": "IP-cím: {0}", "LabelRunningTimeValue": "Lejátszási idő: {0}", "Latest": "Legújabb", - "MessageApplicationUpdated": "A Jellyfin kiszolgáló frissítve lett", - "MessageApplicationUpdatedTo": "A Jellyfin kiszolgáló frissítve lett a következőre: {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "A kiszolgálókonfigurációs rész frissítve lett: {0}", - "MessageServerConfigurationUpdated": "A kiszolgálókonfiguráció frissítve lett", "MixedContent": "Vegyes tartalom", "Movies": "Filmek", "Music": "Zenék", - "MusicVideos": "Zenei videóklipek", + "MusicVideos": "Zenei videók", "NameInstallFailed": "{0} sikertelen telepítés", "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Videólejátszás elkezdve", "NotificationOptionVideoPlaybackStopped": "Videólejátszás leállítva", "Photos": "Fényképek", - "Playlists": "Lejátszási listák", - "Plugin": "Bővítmény", "PluginInstalledWithName": "{0} telepítve", "PluginUninstalledWithName": "{0} eltávolítva", "PluginUpdatedWithName": "{0} frissítve", - "ProviderValue": "Szolgáltató: {0}", "ScheduledTaskFailedWithName": "{0} sikertelen", - "ScheduledTaskStartedWithName": "{0} elkezdve", - "ServerNameNeedsToBeRestarted": "A(z) {0} újraindítása szükséges", "Shows": "Sorozatok", - "Songs": "Számok", "StartupEmbyServerIsLoading": "A Jellyfin kiszolgáló betöltődik. Próbálja újra hamarosan.", "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0}, ehhez: {1}", - "Sync": "Szinkronizálás", - "System": "Rendszer", "TvShows": "TV műsorok", - "User": "Felhasználó", "UserCreatedWithName": "{0} felhasználó létrehozva", "UserDeletedWithName": "{0} felhasználó törölve", "UserDownloadingItemWithValues": "{0} letölti: {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} kijelentkezett innen: {1}", "UserOnlineFromDevice": "{0} online innen: {1}", "UserPasswordChangedWithName": "{0} jelszava megváltozott", - "UserPolicyUpdatedWithName": "{0} felhasználói házirendje frissült", "UserStartedPlayingItemWithValues": "{0} elkezdte lejátszani a következőt: {1}, itt: {2}", "UserStoppedPlayingItemWithValues": "{0} befejezte a következő lejátszását: {1}, itt: {2}", - "ValueHasBeenAddedToLibrary": "{0} hozzáadva a médiatárhoz", - "ValueSpecialEpisodeName": "Különkiadás – {0}", "VersionNumber": "Verzió: {0}", "TaskCleanTranscode": "Átkódolási könyvtár ürítése", "TaskUpdatePluginsDescription": "Letölti és telepíti a frissítéseket azokhoz a bővítményekhez, amelyeknél az automatikus frissítés engedélyezve van.", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.", "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" + "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat", + "Original": "Eredeti", + "LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen" } diff --git a/Emby.Server.Implementations/Localization/Core/hy.json b/Emby.Server.Implementations/Localization/Core/hy.json index 563f842923..b79b540bf4 100644 --- a/Emby.Server.Implementations/Localization/Core/hy.json +++ b/Emby.Server.Implementations/Localization/Core/hy.json @@ -2,38 +2,27 @@ "TasksLibraryCategory": "Գրադարան", "TasksApplicationCategory": "Հավելված", "TaskCleanActivityLog": "Մաքրել ակտիվության մատյանը", - "Application": "Հավելված", "AuthenticationSucceededWithUserName": "{0} հաջողությամբ վավերականացվել են", "Books": "Գրքեր", - "CameraImageUploadedFrom": "Նոր լուսանկար է վերբեռնվել {0}-ի կողմից", - "Channels": "Ալիքներ", - "DeviceOfflineWithName": "{0}ը անջատվեց", "External": "Արտաքին", "FailedLoginAttemptWithUserName": "Ձախողված մուտքի փործ {0}-ի կողմից", "Folders": "Պանակներ", "HeaderContinueWatching": "Շարունակել դիտումը", "Inherit": "Ժառանգել", - "ItemAddedWithName": "{0}ը ավացված է գրադարանի մեջ", - "ItemRemovedWithName": "{0}ը հեռացված է գրադարանից", "LabelIpAddressValue": "IP հասցե` {0}", "Movies": "Ֆիլմեր", "Music": "Երաժշտություն", "NameSeasonNumber": "Սեզոն {0}", "Photos": "Լուսանկարներ", "PluginInstalledWithName": "{0}ն տեղադրված է", - "Songs": "Երգեր", - "System": "Համակարգ", "TvShows": "Հեռուստասերիալներ", - "User": "Օգտատեր", "VersionNumber": "Տարբերակ {0}", "TasksMaintenanceCategory": "Սպասարկում", "TasksChannelsCategory": "Ինտերնետային ալիքներ", "TaskRefreshPeople": "Թարմացնել մարդկանց", "TaskRefreshChannels": "Թարմացնել ալիքները", "TaskDownloadMissingSubtitles": "Ներբեռնել պակասող ենթագրերը", - "Albums": "Ալբոմներ", "AppDeviceValues": "Հավելված` {0}, Սարք `{1}", "ChapterNameValue": "Գլուխ {0}", - "Collections": "Հավաքածուներ", - "DeviceOnlineWithName": "{0}-ն միացված է" + "Collections": "Հավաքածուներ" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index fb228baf40..3502ec39ad 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -1,48 +1,31 @@ { - "Albums": "Album", "AuthenticationSucceededWithUserName": "{0} berhasil diautentikasi", "AppDeviceValues": "Aplikasi : {0}, Perangkat : {1}", "LabelRunningTimeValue": "Waktu berjalan: {0}", - "MessageApplicationUpdatedTo": "Jellyfin Server sudah diperbarui ke {0}", - "MessageApplicationUpdated": "Jellyfin Server sudah diperbarui", "Latest": "Terbaru", "LabelIpAddressValue": "Alamat IP: {0}", - "ItemRemovedWithName": "{0} sudah dihapus dari pustaka", - "ItemAddedWithName": "{0} telah dimasukkan ke dalam pustaka", "Inherit": "Warisi", "HomeVideos": "Video Rumahan", - "HeaderRecordingGroups": "Grup Rekaman", "HeaderNextUp": "Selanjutnya", "HeaderLiveTV": "Siaran langsung", - "HeaderFavoriteSongs": "Lagu Favorit", "HeaderFavoriteShows": "Tayangan Favorit", "HeaderFavoriteEpisodes": "Episode Favorit", - "HeaderFavoriteArtists": "Artis Favorit", - "HeaderFavoriteAlbums": "Album Favorit", "HeaderContinueWatching": "Lanjut Menonton", - "HeaderAlbumArtists": "Album Artis", "Genres": "Aliran", "Folders": "Folder", "Favorites": "Favorit", "Collections": "Koleksi", "Books": "Buku", "Artists": "Artis", - "Application": "Aplikasi", "ChapterNameValue": "Bagian {0}", - "Channels": "Saluran", "TvShows": "Seri TV", "SubtitleDownloadFailureFromForItem": "Subtitel gagal diunduh dari {0} untuk {1}", "StartupEmbyServerIsLoading": "Server Jellyfin sedang dimuat. Silakan coba lagi nanti.", - "Songs": "Lagu", - "Playlists": "Daftar putar", "NotificationOptionPluginUninstalled": "Plugin dihapus", "MusicVideos": "Video Musik", "VersionNumber": "Versi {0}", - "ValueSpecialEpisodeName": "Spesial - {0}", - "ValueHasBeenAddedToLibrary": "{0} telah ditambahkan ke pustaka media Anda", "UserStoppedPlayingItemWithValues": "{0} telah selesai memutar {1} pada {2}", "UserStartedPlayingItemWithValues": "{0} sedang memutar {1} pada {2}", - "UserPolicyUpdatedWithName": "Kebijakan pengguna telah diperbarui untuk {0}", "UserPasswordChangedWithName": "Kata sandi telah diubah untuk pengguna {0}", "UserOnlineFromDevice": "{0} sedang daring dari {1}", "UserOfflineFromDevice": "{0} telah terputus dari {1}", @@ -50,17 +33,10 @@ "UserDownloadingItemWithValues": "{0} sedang mengunduh {1}", "UserDeletedWithName": "Pengguna {0} telah dihapus", "UserCreatedWithName": "Pengguna {0} telah dibuat", - "User": "Pengguna", - "System": "Sistem", - "Sync": "Sinkron", "Shows": "Tayangan", - "ServerNameNeedsToBeRestarted": "{0} perlu dimuat ulang", - "ScheduledTaskStartedWithName": "{0} dimulai", "ScheduledTaskFailedWithName": "{0} gagal", - "ProviderValue": "Penyedia: {0}", "PluginUpdatedWithName": "{0} telah diperbarui", "PluginInstalledWithName": "{0} telah dipasang", - "Plugin": "Plugin", "Photos": "Foto", "NotificationOptionUserLockedOut": "Pengguna terkunci", "NotificationOptionTaskFailed": "Kegagalan tugas terjadwal", @@ -79,12 +55,7 @@ "NameInstallFailed": "{0} penginstalan gagal", "Music": "Musik", "Movies": "Film", - "MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui", - "MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui", "FailedLoginAttemptWithUserName": "Gagal upaya login dari {0}", - "CameraImageUploadedFrom": "Sebuah gambar kamera baru telah diunggah dari {0}", - "DeviceOfflineWithName": "{0} telah terputus", - "DeviceOnlineWithName": "{0} telah terhubung", "NotificationOptionVideoPlaybackStopped": "Pemutaran video berhenti", "NotificationOptionVideoPlayback": "Pemutaran video dimulai", "NotificationOptionAudioPlaybackStopped": "Pemutaran audio berhenti", @@ -135,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 900502ccdd..44e057e4de 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -1,36 +1,22 @@ { "LabelIpAddressValue": "IP tala: {0}", - "ItemRemovedWithName": "{0} var fjarlægt úr safninu", - "ItemAddedWithName": "{0} var bætt í safnið", "Inherit": "Erfa", "HomeVideos": "Heimamyndbönd", - "HeaderRecordingGroups": "Upptökuhópar", "HeaderNextUp": "Næst á dagskrá", "HeaderLiveTV": "Sjónvarp í beinni útsendingu", - "HeaderFavoriteSongs": "Uppáhalds Lög", "HeaderFavoriteShows": "Uppáhalds Sjónvarpsþættir", "HeaderFavoriteEpisodes": "Uppáhalds Þættir", - "HeaderFavoriteArtists": "Uppáhalds Listamenn", - "HeaderFavoriteAlbums": "Uppáhalds Plötur", "HeaderContinueWatching": "Halda áfram að horfa", - "HeaderAlbumArtists": "Listamaður á umslagi", "Genres": "Stefnur", "Folders": "Möppur", "Favorites": "Uppáhalds", "FailedLoginAttemptWithUserName": "{0} mistókst að auðkenna sig", - "DeviceOnlineWithName": "{0} hefur tengst", - "DeviceOfflineWithName": "{0} hefur aftengst", "Collections": "Söfn", "ChapterNameValue": "Kafli {0}", - "Channels": "Rásir", - "CameraImageUploadedFrom": "{0} hefur hlaðið upp nýrri ljósmynd úr myndavél sinni", "Books": "Bækur", "AuthenticationSucceededWithUserName": "Auðkenning fyrir {0} tókst", "Artists": "Listamenn", - "Application": "Forrit", "AppDeviceValues": "Snjallforrit: {0}, Tæki: {1}", - "Albums": "Plötur", - "Plugin": "Viðbótarvirkni", "Photos": "Ljósmyndir", "NotificationOptionVideoPlaybackStopped": "Myndbandsafspilun stöðvuð", "NotificationOptionVideoPlayback": "Myndbandsafspilun hafin", @@ -49,13 +35,8 @@ "NameSeasonUnknown": "Þáttaröð óþekkt", "NameSeasonNumber": "Þáttaröð {0}", "MixedContent": "Blandað efni", - "MessageServerConfigurationUpdated": "Stillingar þjóns hafa verið uppfærðar", - "MessageApplicationUpdatedTo": "Jellyfin þjónn hefur verið uppfærður í {0}", - "MessageApplicationUpdated": "Jellyfin þjónn hefur verið uppfærður", "Latest": "Nýjasta", "LabelRunningTimeValue": "spilunartími: {0}", - "User": "Notandi", - "System": "Kerfi", "NotificationOptionNewLibraryContent": "Nýju efni bætt við", "NewVersionIsAvailable": "Ný útgáfa af Jellyfin þjón er tilbúin til niðurhals.", "NameInstallFailed": "{0} uppsetning mistókst", @@ -65,10 +46,6 @@ "UserDeletedWithName": "Notanda {0} hefur verið eytt", "UserCreatedWithName": "Notandi {0} hefur verið stofnaður", "TvShows": "Sjónvarpsþættir", - "Sync": "Samstilla", - "Songs": "Lög", - "ServerNameNeedsToBeRestarted": "{0} þarf að vera endurræstur", - "ScheduledTaskStartedWithName": "{0} hafin", "ScheduledTaskFailedWithName": "{0} mistókst", "PluginUpdatedWithName": "{0} var uppfært", "PluginUninstalledWithName": "{0} var fjarlægt", @@ -76,21 +53,15 @@ "NotificationOptionTaskFailed": "Tímasett verkefni mistókst", "StartupEmbyServerIsLoading": "Jellyfin netþjónnin er að ræsa sig upp. Vinsamlegast reyndu aftur fljótlega.", "VersionNumber": "Útgáfa {0}", - "ValueHasBeenAddedToLibrary": "{0} hefur verið bætt við í gagnasafnið þitt", "UserStoppedPlayingItemWithValues": "{0} hefur lokið spilunar af {1} á {2}", "UserStartedPlayingItemWithValues": "{0} er að spila {1} á {2}", - "UserPolicyUpdatedWithName": "Notandaregla hefur verið uppfærð fyrir {0}", "UserPasswordChangedWithName": "Lykilorði fyrir notandann {0} hefur verið breytt", "UserOnlineFromDevice": "{0} hefur verið virkur síðan {1}", "UserOfflineFromDevice": "{0} hefur aftengst frá {1}", "UserLockedOutWithName": "Notandi {0} hefur verið læstur úti", "UserDownloadingItemWithValues": "{0} hleður niður {1}", "SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}", - "ProviderValue": "Efnisveita: {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Stilling {0} hefur verið uppfærð á netþjón", - "ValueSpecialEpisodeName": "Sérstaktur - {0}", "Shows": "Þættir", - "Playlists": "Efnisskrár", "TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.", "TaskRefreshChannels": "Endurhlaða Rásir", "TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.", @@ -132,5 +103,8 @@ "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." } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 782f5ce53d..f13944e6be 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -1,41 +1,24 @@ { - "Albums": "Album", "AppDeviceValues": "App: {0}, Dispositivo: {1}", - "Application": "Applicazione", "Artists": "Artisti", "AuthenticationSucceededWithUserName": "{0} autenticato correttamente", "Books": "Libri", - "CameraImageUploadedFrom": "È stata caricata una nuova fotografia da {0}", - "Channels": "Canali", "ChapterNameValue": "Capitolo {0}", "Collections": "Collezioni", - "DeviceOfflineWithName": "{0} si è disconnesso", - "DeviceOnlineWithName": "{0} è connesso", "FailedLoginAttemptWithUserName": "Tentativo di accesso non riuscito da {0}", "Favorites": "Preferiti", "Folders": "Cartelle", "Genres": "Generi", - "HeaderAlbumArtists": "Artisti dell'album", "HeaderContinueWatching": "Continua a guardare", - "HeaderFavoriteAlbums": "Album preferiti", - "HeaderFavoriteArtists": "Artisti preferiti", "HeaderFavoriteEpisodes": "Episodi preferiti", "HeaderFavoriteShows": "Serie TV preferite", - "HeaderFavoriteSongs": "Brani preferiti", "HeaderLiveTV": "Diretta TV", "HeaderNextUp": "Prossimo", - "HeaderRecordingGroups": "Gruppi di registrazione", "HomeVideos": "Video personali", "Inherit": "Eredita", - "ItemAddedWithName": "{0} è stato aggiunto alla libreria", - "ItemRemovedWithName": "{0} è stato rimosso dalla libreria", "LabelIpAddressValue": "Indirizzo IP: {0}", "LabelRunningTimeValue": "Durata: {0}", "Latest": "Novità", - "MessageApplicationUpdated": "Jellyfin Server è stato aggiornato", - "MessageApplicationUpdatedTo": "Jellyfin Server è stato aggiornato a {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La sezione {0} della configurazione server è stata aggiornata", - "MessageServerConfigurationUpdated": "La configurazione del server è stata aggiornata", "MixedContent": "Contenuto misto", "Movies": "Film", "Music": "Musica", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Riproduzione video iniziata", "NotificationOptionVideoPlaybackStopped": "Riproduzione video interrotta", "Photos": "Foto", - "Playlists": "Scalette", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} è stato installato", "PluginUninstalledWithName": "{0} è stato disinstallato", "PluginUpdatedWithName": "{0} è stato aggiornato", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} non riuscito", - "ScheduledTaskStartedWithName": "{0} avviato", - "ServerNameNeedsToBeRestarted": "{0} deve essere riavviato", "Shows": "Serie TV", - "Songs": "Brani", "StartupEmbyServerIsLoading": "Jellyfin Server si sta avviando. Riprova più tardi.", "SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}", - "Sync": "Sincronizza", - "System": "Sistema", "TvShows": "Serie TV", - "User": "Utente", "UserCreatedWithName": "L'utente {0} è stato creato", "UserDeletedWithName": "L'utente {0} è stato eliminato", "UserDownloadingItemWithValues": "{0} sta scaricando {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} si è disconnesso da {1}", "UserOnlineFromDevice": "{0} è online su {1}", "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}", - "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}", "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}", "UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1} su {2}", - "ValueHasBeenAddedToLibrary": "{0} è stato aggiunto alla tua libreria multimediale", - "ValueSpecialEpisodeName": "Speciale - {0}", "VersionNumber": "Versione {0}", "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali internet.", "TaskDownloadMissingSubtitlesDescription": "Cerca su internet i sottotitoli mancanti basandosi sulle configurazioni dei metadati.", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.", "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." + "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.", + "Original": "Originale", + "LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 7b0bdb296f..78b7ec744b 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -1,41 +1,24 @@ { - "Albums": "アルバム", "AppDeviceValues": "アプリ: {0}, デバイス: {1}", - "Application": "アプリケーション", "Artists": "アーティスト", "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", "Books": "ブック", - "CameraImageUploadedFrom": "新しいカメライメージが {0}からアップロードされました", - "Channels": "チャンネル", "ChapterNameValue": "チャプター {0}", "Collections": "コレクション", - "DeviceOfflineWithName": "{0} が切断しました", - "DeviceOnlineWithName": "{0} が接続しました", "FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました", "Favorites": "お気に入り", "Folders": "フォルダー", "Genres": "ジャンル", - "HeaderAlbumArtists": "アルバムアーティスト", "HeaderContinueWatching": "再生を続ける", - "HeaderFavoriteAlbums": "お気に入りのアルバム", - "HeaderFavoriteArtists": "お気に入りのアーティスト", "HeaderFavoriteEpisodes": "お気に入りのエピソード", "HeaderFavoriteShows": "お気に入りの番組", - "HeaderFavoriteSongs": "お気に入りの曲", "HeaderLiveTV": "ライブTV", "HeaderNextUp": "次", - "HeaderRecordingGroups": "レコーディンググループ", "HomeVideos": "ホームビデオ", "Inherit": "継承", - "ItemAddedWithName": "{0} をライブラリーに追加しました", - "ItemRemovedWithName": "{0} をライブラリーから削除しました", "LabelIpAddressValue": "IPアドレス: {0}", "LabelRunningTimeValue": "時間: {0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin Server を更新しました", - "MessageApplicationUpdatedTo": "Jellyfin Server を {0}に更新しました", - "MessageNamedServerConfigurationUpdatedWithValue": "サーバー設定項目の {0} を更新しました", - "MessageServerConfigurationUpdated": "サーバー設定を更新しました", "MixedContent": "ミックスコンテンツ", "Movies": "映画", "Music": "音楽", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "ビデオの再生を開始", "NotificationOptionVideoPlaybackStopped": "ビデオの再生を停止", "Photos": "フォト", - "Playlists": "プレイリスト", - "Plugin": "プラグイン", "PluginInstalledWithName": "{0} をインストールしました", "PluginUninstalledWithName": "{0} をアンインストールしました", "PluginUpdatedWithName": "{0} を更新しました", - "ProviderValue": "プロバイダ: {0}", "ScheduledTaskFailedWithName": "{0} が失敗しました", - "ScheduledTaskStartedWithName": "{0} を開始", - "ServerNameNeedsToBeRestarted": "{0} を再起動してください", "Shows": "番組", - "Songs": "曲", "StartupEmbyServerIsLoading": "Jellyfin Server は現在読み込み中です。しばらくしてからもう一度お試しください。", "SubtitleDownloadFailureFromForItem": "{0} から {1}の字幕のダウンロードに失敗しました", - "Sync": "同期", - "System": "システム", "TvShows": "テレビ番組", - "User": "ユーザー", "UserCreatedWithName": "ユーザー {0} が作成されました", "UserDeletedWithName": "User {0} を削除しました", "UserDownloadingItemWithValues": "{0} が {1} をダウンロードしています", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} は {1} から切断しました", "UserOnlineFromDevice": "{0} は {1} からオンラインになりました", "UserPasswordChangedWithName": "ユーザー {0} のパスワードは変更されました", - "UserPolicyUpdatedWithName": "ユーザーポリシーが{0}に更新されました", "UserStartedPlayingItemWithValues": "{0} は {2}で{1} を再生しています", "UserStoppedPlayingItemWithValues": "{0} は{2}で{1} の再生が終わりました", - "ValueHasBeenAddedToLibrary": "{0} をメディアライブラリーに追加しました", - "ValueSpecialEpisodeName": "スペシャル - {0}", "VersionNumber": "バージョン {0}", "TaskCleanLogsDescription": "{0} 日以上前のログを消去します。", "TaskCleanLogs": "ログの掃除", @@ -135,5 +106,7 @@ "TaskDownloadMissingLyrics": "失われた歌詞をダウンロード", "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。", "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク", - "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。" + "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。", + "LyricDownloadFailureFromForItem": "歌詞", + "Original": "オリジナル" } diff --git a/Emby.Server.Implementations/Localization/Core/jbo.json b/Emby.Server.Implementations/Localization/Core/jbo.json index 1b47bb2f23..50d6d49601 100644 --- a/Emby.Server.Implementations/Localization/Core/jbo.json +++ b/Emby.Server.Implementations/Localization/Core/jbo.json @@ -1,7 +1,4 @@ { - "Albums": "lo albuma", "Artists": "lo larpra", - "Books": "lo cukta", - "HeaderAlbumArtists": "lo albuma larpra", - "Playlists": "lo zgipor" + "Books": "lo cukta" } diff --git a/Emby.Server.Implementations/Localization/Core/ka.json b/Emby.Server.Implementations/Localization/Core/ka.json index 4f291e466b..f7ca19d7f0 100644 --- a/Emby.Server.Implementations/Localization/Core/ka.json +++ b/Emby.Server.Implementations/Localization/Core/ka.json @@ -1,12 +1,8 @@ { "Genres": "ჟანრები", - "HeaderAlbumArtists": "ალბომის შემსრულებლები", - "HeaderFavoriteAlbums": "რჩეული ალბომები", "TasksApplicationCategory": "აპლიკაცია", - "Albums": "ალბომები", "AppDeviceValues": "აპლიკაცია: {0}, მოწყობილობა: {1}", - "Application": "აპლიკაცია", - "Artists": "არტისტი", + "Artists": "შემსრულებლები", "AuthenticationSucceededWithUserName": "{0} -ის ავთენტიკაცია წარმატებულია", "Books": "წიგნები", "Forced": "იძულებითი", @@ -15,106 +11,81 @@ "Movies": "ფილმები", "Music": "მუსიკა", "Photos": "ფოტოები", - "Playlists": "დასაკრავი სიები", - "Plugin": "მოდული", "Shows": "სერიალები", - "Songs": "სიმღერები", - "Sync": "სინქრონიზაცია", - "System": "სისტემა", "Undefined": "განუსაზღვრელი", - "User": "მომხმარებელი", "TasksMaintenanceCategory": "რემონტი", "TasksLibraryCategory": "ბიბლიოთეკა", "ChapterNameValue": "თავი {0}", "HeaderContinueWatching": "ყურების გაგრძელება", - "HeaderFavoriteArtists": "რჩეული შემსრულებლები", - "DeviceOfflineWithName": "{0} გამოეთიშა", "External": "გარე", "HeaderFavoriteEpisodes": "რჩეული ეპიზოდები", - "HeaderFavoriteSongs": "რჩეული სიმღერები", - "HeaderRecordingGroups": "ჩამწერი ჯგუფები", "HearingImpaired": "სმენადაქვეითებული", - "LabelRunningTimeValue": "ხანგრძლივობა: {0}", - "MessageApplicationUpdatedTo": "Jellyfin-ის სერვერი განახლდა {0}-ზე", - "MessageNamedServerConfigurationUpdatedWithValue": "სერვერის კონფიგურაციის სექცია {0} განახლდა", + "LabelRunningTimeValue": "გაშვების დრო: {0}", "MixedContent": "შერეული შემცველობა", - "MusicVideos": "მუსიკალური ვიდეოები", + "MusicVideos": "მუსიკის ვიდეოები", "NotificationOptionInstallationFailed": "დაყენების შეცდომა", "NotificationOptionApplicationUpdateInstalled": "აპლიკაციის განახლება დაყენებულია", "NotificationOptionAudioPlayback": "აუდიოს დაკვრა დაწყებულია", "NotificationOptionCameraImageUploaded": "კამერის გამოსახულება ატვირთულია", "NotificationOptionVideoPlaybackStopped": "ვიდეოს დაკვრა გაჩერებულია", "PluginUninstalledWithName": "{0} წაიშალა", - "ScheduledTaskStartedWithName": "{0} დაიწყო", "VersionNumber": "ვერსია {0}", "TasksChannelsCategory": "ინტერნეტ-არხები", - "ValueSpecialEpisodeName": "დამატებითი - {0}", - "TaskRefreshChannelsDescription": "ინტერნეტ-არხის ინფორმაციის განახლება.", - "Channels": "არხები", + "TaskRefreshChannelsDescription": "განაახლებს ინტერნეტ-არხის ინფორმაციას.", "Collections": "კოლექციები", - "Default": "ნაგულისხმები", + "Default": "ნაგულისხმევი", "Favorites": "რჩეულები", "Folders": "საქაღალდეები", "HeaderFavoriteShows": "რჩეული სერიალები", - "HeaderLiveTV": "ლაივ ტელევიზია", + "HeaderLiveTV": "ცოცხალი ტელევიზია", "HeaderNextUp": "შემდეგი", "HomeVideos": "სახლის ვიდეოები", "NameSeasonNumber": "სეზონი {0}", "NameSeasonUnknown": "სეზონი უცნობია", - "NotificationOptionPluginError": "მოდულის შეცდომა", - "NotificationOptionPluginInstalled": "მოდული დაყენებულია", + "NotificationOptionPluginError": "დამატების შეცდომა", + "NotificationOptionPluginInstalled": "დამატება დაყენებულია", "NotificationOptionPluginUninstalled": "მოდული წაიშალა", - "ProviderValue": "მომწოდებელი: {0}", - "ScheduledTaskFailedWithName": "{0} ვერ შესრულდა", + "ScheduledTaskFailedWithName": "{0} ჩავარდა", "TvShows": "სატელევიზიო სერიალები", "TaskRefreshPeople": "ხალხის განახლება", - "TaskUpdatePlugins": "მოდულების განახლება", + "TaskUpdatePlugins": "დამატებების განახლება", "TaskRefreshChannels": "არხების განახლება", "TaskOptimizeDatabase": "მონაცემთა ბაზის ოპტიმიზაცია", "TaskKeyframeExtractor": "საკვანძო კადრის გამომღები", - "DeviceOnlineWithName": "{0} დაკავშირდა", "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}-დან", - "MessageApplicationUpdated": "Jellyfin-ის სერვერი განახლდა", - "MessageServerConfigurationUpdated": "სერვერის კონფიგურაცია განახლდა", - "ServerNameNeedsToBeRestarted": "საჭიროა {0}-ის გადატვირთვა", "UserCreatedWithName": "მომხმარებელი {0} შეიქმნა", - "UserDeletedWithName": "მომხმარებელი {0} წაშლილია", - "UserOnlineFromDevice": "{0}-ი დაკავშირდა {1}-დან", - "UserOfflineFromDevice": "{0}-ი {1}-დან გაეთიშა", - "ItemAddedWithName": "{0} ჩამატებულია ბიბლიოთეკაში", - "ItemRemovedWithName": "{0} წაშლილია ბიბლიოთეკიდან", + "UserDeletedWithName": "მომხმარებელი {0} წაიშალა", + "UserOnlineFromDevice": "{0} ხაზზეა {1}-დან", + "UserOfflineFromDevice": "{0} გაითიშა {1}-დან", "UserLockedOutWithName": "მომხმარებელი {0} დაბლოკილია", - "UserStartedPlayingItemWithValues": "{0} უყურებს {1}-ს {2}-ზე", + "UserStartedPlayingItemWithValues": "{0} უკრავს {1}-ს {2}-ზე", "UserPasswordChangedWithName": "მომხმარებელი {0}-სთვის პაროლი შეიცვალა", - "UserPolicyUpdatedWithName": "{0}-ის მომხმარებლის პოლიტიკა განახლდა", "UserStoppedPlayingItemWithValues": "{0}-მა დაასრულა {1}-ის ყურება {2}-ზე", "TaskRefreshChapterImagesDescription": "თავების მქონე ვიდეოებისთვის მინიატურების შექმნა.", "TaskKeyframeExtractorDescription": "უფრო ზუსტი HLS დასაკრავი სიებისითვის ვიდეოდან საკვანძო გადრების ამოღება. შეიძლება საკმაო დრო დასჭირდეს.", "NewVersionIsAvailable": "გადმოსაწერად ხელმისაწვდომია Jellyfin -ის ახალი ვერსია.", - "CameraImageUploadedFrom": "ახალი კამერის გამოსახულება ატვირთულია {0}-დან", "StartupEmbyServerIsLoading": "Jellyfin სერვერი იტვირთება. მოგვიანებით სცადეთ.", "SubtitleDownloadFailureFromForItem": "{0}-დან {1}-სთვის სუბტიტრების გადმოწერა ვერ შესრულდა", - "ValueHasBeenAddedToLibrary": "{0} დაემატა თქვენს მედიის ბიბლიოთეკას", "TaskCleanActivityLogDescription": "შლის მითითებულ ასაკზე ძველ ჟურნალის ჩანაწერებს.", "TaskCleanCacheDescription": "შლის სისტემისთვის არასაჭირო ქეშის ფაილებს.", "TaskRefreshLibraryDescription": "ეძებს ახალ ფაილებს თქვენს მედიის ბიბლიოთეკაში და ანახლებს მეტამონაცემებს.", @@ -125,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/kab.json b/Emby.Server.Implementations/Localization/Core/kab.json index 9551f0e5c1..0d0932b585 100644 --- a/Emby.Server.Implementations/Localization/Core/kab.json +++ b/Emby.Server.Implementations/Localization/Core/kab.json @@ -1,14 +1,10 @@ { "Music": "Aẓawan", - "Sync": "Amtawi", "Photos": "Tiwlafin", "Movies": "Isura", "External": "Azɣaray", - "User": "Aseqdac", "Folders": "Ikaramen", "Favorites": "Ismenyifen", "Default": "Lexṣas", - "Collections": "Tigrummiwin", - "Channels": "Ibuda", - "Albums": "Iseɣraz" + "Collections": "Tigrummiwin" } diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index fc5fcf3c4d..ddcf60944d 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -1,41 +1,24 @@ { - "Albums": "Älbomdar", "AppDeviceValues": "Qoldanba: {0}, Qūrylğy: {1}", - "Application": "Qoldanba", "Artists": "Oryndauşylar", "AuthenticationSucceededWithUserName": "{0} tüpnūsqalyq rastaluy sättı aiaqtaldy", "Books": "Kıtaptar", - "CameraImageUploadedFrom": "{0} kamerasynan jaña suret jüktep salyndy", - "Channels": "Arnalar", "ChapterNameValue": "{0}-sahna", "Collections": "Jiyntyqtar", - "DeviceOfflineWithName": "{0} ajyratylğan", - "DeviceOnlineWithName": "{0} qosylğan", "FailedLoginAttemptWithUserName": "{0} tarapynan kıru äreketı sätsız aiaqtaldy", "Favorites": "Tañdaulylar", "Folders": "Qaltalar", "Genres": "Janrlar", - "HeaderAlbumArtists": "Älbom oryndauşylary", "HeaderContinueWatching": "Qaraudy jalğastyru", - "HeaderFavoriteAlbums": "Tañdauly älbomdar", - "HeaderFavoriteArtists": "Tañdauly oryndauşylar", "HeaderFavoriteEpisodes": "Tañdauly telebölımder", "HeaderFavoriteShows": "Tañdauly körsetımder", - "HeaderFavoriteSongs": "Tañdauly äuender", "HeaderLiveTV": "Efir", "HeaderNextUp": "Kezektı", - "HeaderRecordingGroups": "Jazba toptary", "HomeVideos": "Üilık beineler", "Inherit": "İelenu", - "ItemAddedWithName": "{0} tasyğyşhanağa üstelindı", - "ItemRemovedWithName": "{0} tasyğyşhanadan alastaldy", "LabelIpAddressValue": "IP-mekenjaiy: {0}", "LabelRunningTimeValue": "Oinatu uaqyty: {0}", "Latest": "Eñ keiıngı", - "MessageApplicationUpdated": "Jellyfin Serverı jañartyldy", - "MessageApplicationUpdatedTo": "Jellyfin Serverı {0} nūsqasyna jañartyldy", - "MessageNamedServerConfigurationUpdatedWithValue": "Server teñşelımderınıñ {0} bölımı jañartyldy", - "MessageServerConfigurationUpdated": "Server teñşelımderı jañartyldy", "MixedContent": "Aralas mazmūn", "Movies": "Filmder", "Music": "Muzyka", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Beine oinatuy bastaldy", "NotificationOptionVideoPlaybackStopped": "Beine oinatuy toqtatyldy", "Photos": "Fotosuretter", - "Playlists": "Oinatu tızımderı", - "Plugin": "Plagin", "PluginInstalledWithName": "{0} ornatyldy", "PluginUninstalledWithName": "{0} joiyldy", "PluginUpdatedWithName": "{0} jañartyldy", - "ProviderValue": "Jetkızuşı: {0}", "ScheduledTaskFailedWithName": "{0} sätsız", - "ScheduledTaskStartedWithName": "{0} ıske qosyldy", - "ServerNameNeedsToBeRestarted": "{0} qaita ıske qosu qajet", "Shows": "Körsetımder", - "Songs": "Äuender", "StartupEmbyServerIsLoading": "Jellyfin Server jüktelude. Ärekettı köp ūzamai qaitalañyz.", "SubtitleDownloadFailureFromForItem": "{1} üşın subtitrlerdı {0} közınen jüktep alu sätsız", - "Sync": "Ündestıru", - "System": "Jüie", "TvShows": "TD-körsetımder", - "User": "Paidalanuşy", "UserCreatedWithName": "Paidalanuşy {0} jasalğan", "UserDeletedWithName": "Paidalanuşy {0} joiylğan", "UserDownloadingItemWithValues": "{0} — {1} jüktep aluda", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} — {1} tarapynan ajyratyldy", "UserOnlineFromDevice": "{0} — {1} tarapynan qosyldy", "UserPasswordChangedWithName": "Paidalanuşy {0} üşın paröl özgertıldı", - "UserPolicyUpdatedWithName": "Paidalanuşy {0} üşın saiasattary jañartyldy", "UserStartedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuda", "UserStoppedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuyn toqtatty", - "ValueHasBeenAddedToLibrary": "{0} tasyğyşhanağa üstelındı", - "ValueSpecialEpisodeName": "Arnaiy - {0}", "VersionNumber": "Nūsqasy {0}", "Default": "Ädepkı", "TaskDownloadMissingSubtitles": "Joq subtitrlerdı jüktep alu", diff --git a/Emby.Server.Implementations/Localization/Core/km.json b/Emby.Server.Implementations/Localization/Core/km.json index c40b96cf24..b4057eb8eb 100644 --- a/Emby.Server.Implementations/Localization/Core/km.json +++ b/Emby.Server.Implementations/Localization/Core/km.json @@ -1,88 +1,62 @@ { - "Albums": "អាលប៊ុម", - "MessageApplicationUpdatedTo": "ម៉ាស៊ីនមេនៃJellyfinត្រូវបានអាប់ដេតទៅកាន់ {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "ការកំណត់ម៉ាស៊ីនមេ ផ្នែក {0} ត្រូវបានអាប់ដេត", - "MessageServerConfigurationUpdated": "ការកំណត់ម៉ាស៊ីនមេត្រូវបានអាប់ដេត", "AppDeviceValues": "កម្មវិធី: {0}, ឧបករណ៍: {1}", "MixedContent": "មាតិកាចម្រុះ", "UserLockedOutWithName": "អ្នកប្រើប្រាស់ {0} ត្រូវបានផ្អាក", - "Application": "កម្មវិធី", "Artists": "សិល្បករ", "AuthenticationSucceededWithUserName": "{0} បានផ្ទៀងផ្ទាត់ដោយជោគជ័យ", "Books": "សៀវភៅ", "NameSeasonNumber": "រដូវកាលទី {0}", "NotificationOptionPluginInstalled": "Plugin បានដំឡើងរួច", - "CameraImageUploadedFrom": "រូបភាពកាមេរ៉ាថ្មីត្រូវបានបង្ហោះពី {0}", - "Channels": "ប៉ុស្ត៍", "ChapterNameValue": "ជំពូក {0}", "Collections": "បណ្តុំ", "External": "ខាងក្រៅ", "Default": "លំនាំដើម", "NotificationOptionInstallationFailed": "ការដំឡើងមិនបានសម្រេច", - "DeviceOfflineWithName": "{0} បានផ្តាច់", "Folders": "ថតឯកសារ", - "DeviceOnlineWithName": "{0} បានភ្ចាប់", "HearingImpaired": "ខ្សោយការស្តាប់", "HomeVideos": "វីឌីអូថតខ្លួនឯង", "Favorites": "ចំណូលចិត្ត", "HeaderFavoriteEpisodes": "ភាគដែលចូលចិត្ត", "Forced": "បង្ខំ", "Genres": "ប្រភេទ", - "HeaderFavoriteArtists": "សិល្បករដែលចូលចិត្ត", "NotificationOptionApplicationUpdateAvailable": "កម្មវិធី យើងអាចអាប់ដេតបាន", "NotificationOptionApplicationUpdateInstalled": "កម្មវិធី ដែលបានដំឡើងរួច", "NotificationOptionAudioPlaybackStopped": "ការចាក់សម្លេងបានផ្អាក", "HeaderContinueWatching": "បន្តមើល", - "HeaderFavoriteAlbums": "អាល់ប៊ុមដែលចូលចិត្ត", "HeaderFavoriteShows": "រឿងភាគដែលចូលចិត្ត", "NewVersionIsAvailable": "មានជំនាន់ថ្មី ម៉ាស៊ីនមេJellyfin អាចទាញយកបាន.", - "HeaderAlbumArtists": "សិល្បករអាល់ប៊ុម", "NotificationOptionCameraImageUploaded": "រូបភាពពីកាំមេរ៉ាបានអាប់ឡូតរួច", - "HeaderFavoriteSongs": "ចម្រៀងដែលចូលចិត្ត", "HeaderNextUp": "បន្ទាប់", "HeaderLiveTV": "ទូរទស្សន៍ផ្សាយផ្ទាល់", "Movies": "រឿង", - "HeaderRecordingGroups": "ក្រុមនៃការថត", "Music": "តន្ត្រី", "Inherit": "មរតក", "MusicVideos": "វីដេអូតន្ត្រី", "NameInstallFailed": "{0} ការដំឡើងបានបរាជ័យ", "NotificationOptionNewLibraryContent": "មាតិកាថ្មីៗត្រូវបានបន្ថែម", - "ItemAddedWithName": "{0} ត្រូវបានបន្ថែមទៅបណ្ណាល័យ", "NameSeasonUnknown": "រដូវកាលមិនច្បាស់លាស់", - "ItemRemovedWithName": "{0} ត្រូវបានដកចេញពីបណ្ណាល័យ", "LabelIpAddressValue": "លេខ IP: {0}", "LabelRunningTimeValue": "ពេលវេលាកំពុងដំណើរការ: {0}", "Latest": "ចុងក្រោយ", "NotificationOptionAudioPlayback": "ការចាក់សំឡេងបានចាប់ផ្ដើម", "NotificationOptionPluginError": "Plugin មិនដំណើរការ", "NotificationOptionPluginUninstalled": "Plugin បានលុបចេញរួច", - "MessageApplicationUpdated": "ម៉ាស៊ីនមេនៃJellyfinត្រូវបានអាប់ដេត", "NotificationOptionPluginUpdateInstalled": "Plugin អាប់ដេតបានដំឡើងរួច", "NotificationOptionUserLockedOut": "អ្នកប្រើប្រាស់ត្រូវបានជាប់គាំង", "NotificationOptionServerRestartRequired": "តម្រូវឱ្យចាប់ផ្ដើមម៉ាស៊ីនមេឡើងវិញ", "Photos": "រូបថត", - "Playlists": "បញ្ជីចាក់", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} ត្រូវបានដំឡើង", "NotificationOptionTaskFailed": "កិច្ចការដែលបានគ្រោងទុកបានបរាជ័យ", "PluginUpdatedWithName": "{0} ត្រូវបានអាប់ដេត", "NotificationOptionVideoPlayback": "ការចាក់វីដេអូបានចាប់ផ្តើម", - "Songs": "ចម្រៀង", - "ScheduledTaskStartedWithName": "{0} បានចាប់ផ្តើម", "NotificationOptionVideoPlaybackStopped": "ការចាក់វីដេអូបានបញ្ឈប់", "PluginUninstalledWithName": "{0} ត្រូវបានលុបចេញ", "Shows": "រឿងភាគ", - "ProviderValue": "អ្នកផ្តល់សេវា: {0}", "SubtitleDownloadFailureFromForItem": "សាប់ថាយថលបានបរាជ័យក្នុងការទាញយកពី {0} នៃ {1}", - "Sync": "ធ្វើអោយដំណាលគ្នា", - "System": "ប្រព័ន្ធ", "TvShows": "កម្មវិធីទូរទស្សន៍", "ScheduledTaskFailedWithName": "{0} បានបរាជ័យ", "Undefined": "មិនបានកំណត់", - "User": "អ្នកប្រើប្រាស់", "UserCreatedWithName": "អ្នកប្រើប្រាស់ {0} ត្រូវបានបង្កើតឡើង", - "ServerNameNeedsToBeRestarted": "{0} ចាំបាច់ត្រូវចាប់ផ្តើមឡើងវិញ", "StartupEmbyServerIsLoading": "ម៉ាស៊ីនមេJellyfin កំពុងដំណើរការ. សូមព្យាយាមម្តងទៀតក្នុងពេលឆាប់ៗនេះ.", "UserDeletedWithName": "អ្នកប្រើប្រាស់ {0} ត្រូវបានលុបចេញ", "UserOnlineFromDevice": "{0} បានឃើញអនឡានពី {1}", @@ -98,10 +72,7 @@ "UserPasswordChangedWithName": "ពាក្យសម្ងាត់ត្រូវបានផ្លាស់ប្តូរសម្រាប់អ្នកប្រើប្រាស់ {0}", "TaskCleanCache": "សម្អាតបញ្ជីឃ្លាំងសម្ងាត់", "TaskRefreshChapterImages": "ដកស្រង់រូបភាពតាមជំពូក", - "UserPolicyUpdatedWithName": "គោលការណ៍អ្នកប្រើប្រាស់ត្រូវបានធ្វើបច្ចុប្បន្នភាពសម្រាប់ {0}", "UserStoppedPlayingItemWithValues": "{0} បានបញ្ចប់ការចាក់ {1} នៅលើ {2}", - "ValueHasBeenAddedToLibrary": "{0} ត្រូវបានបញ្ចូលទៅក្នុងបណ្ណាល័យរឿងរបស់អ្នក", - "ValueSpecialEpisodeName": "ពិសេស - {0}", "TasksChannelsCategory": "ប៉ុស្តតាមអ៊ីនធឺណិត", "TaskAudioNormalization": "ធ្វើឱ្យមានតន្ត្រីមានសម្លេងស្មើគ្នា", "TaskCleanActivityLogDescription": "លុបកំណត់ហេតុសកម្មភាពចាស់ជាងអាយុដែលបានកំណត់រចនាសម្ព័ន្ធ.", diff --git a/Emby.Server.Implementations/Localization/Core/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json index 0850600588..6009b50fe0 100644 --- a/Emby.Server.Implementations/Localization/Core/kn.json +++ b/Emby.Server.Implementations/Localization/Core/kn.json @@ -4,8 +4,6 @@ "TaskOptimizeDatabaseDescription": "ಡೇಟಾಬೇಸ್ ಅನ್ನು ಕಾಂಪ್ಯಾಕ್ಟ್ ಮಾಡುತ್ತದೆ ಮತ್ತು ಮುಕ್ತ ಜಾಗವನ್ನು ಮೊಟಕುಗೊಳಿಸುತ್ತದೆ. ಲೈಬ್ರರಿಯನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿದ ನಂತರ ಈ ಕಾರ್ಯವನ್ನು ನಡೆಸುವುದು ಅಥವಾ ಡೇಟಾಬೇಸ್ ಮಾರ್ಪಾಡುಗಳನ್ನು ಸೂಚಿಸುವ ಇತರ ಬದಲಾವಣೆಗಳನ್ನು ಮಾಡುವುದರಿಂದ ಕಾರ್ಯಕ್ಷಮತೆಯನ್ನು ಸುಧಾರಿಸಬಹುದು.", "TaskKeyframeExtractor": "ಕೀಫ್ರೇಮ್ ಎಕ್ಸ್ಟ್ರಾಕ್ಟರ್", "TaskKeyframeExtractorDescription": "ಹೆಚ್ಚು ನಿಖರವಾದ HLS ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ರಚಿಸಲು ವೀಡಿಯೊ ಫೈಲ್ಗಳಿಂದ ಕೀಫ್ರೇಮ್ಗಳನ್ನು ಹೊರತೆಗೆಯುತ್ತದೆ. ಈ ಕಾರ್ಯವು ದೀರ್ಘಕಾಲದವರೆಗೆ ನಡೆಯಬಹುದು.", - "ValueHasBeenAddedToLibrary": "{0} ಅನ್ನು ನಿಮ್ಮ ಮಾಧ್ಯಮ ಲೈಬ್ರರಿಗೆ ಸೇರಿಸಲಾಗಿದೆ", - "ValueSpecialEpisodeName": "ವಿಶೇಷ - {0}", "TasksLibraryCategory": "ಸಮೊಹ", "TasksApplicationCategory": "ಅಪ್ಲಿಕೇಶನ್", "TasksChannelsCategory": "ಇಂಟರ್ನೆಟ್ ಚಾನೆಲ್ಗಳು", @@ -13,8 +11,6 @@ "TaskCleanCacheDescription": "ಸಿಸ್ಟಮ್ಗೆ ಇನ್ನು ಮುಂದೆ ಅಗತ್ಯವಿಲ್ಲದ ಸಂಗ್ರಹ ಫೈಲ್ಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", "TaskRefreshLibrary": "ಸ್ಕ್ಯಾನ್ ಮೀಡಿಯಾ ಲೈಬ್ರರಿ", "UserOfflineFromDevice": "{1} ನಿಂದ {0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ", - "Albums": "ಸಂಪುಟ", - "Application": "ಅಪ್ಲಿಕೇಶನ್", "AppDeviceValues": "ಅಪ್ಲಿಕೇಶನ್: {0}, ಸಾಧನ: {1}", "Artists": "ಕಲಾವಿದರು", "AuthenticationSucceededWithUserName": "{0} ಯಶಸ್ವಿಯಾಗಿ ದೃಢೀಕರಿಸಲಾಗಿದೆ", @@ -22,8 +18,6 @@ "ChapterNameValue": "ಅಧ್ಯಾಯ {0}", "Collections": "ಸಂಗ್ರಹಣೆಗಳು", "Default": "ಪೂರ್ವನಿಯೋಜಿತ", - "DeviceOfflineWithName": "{0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ", - "DeviceOnlineWithName": "{0} ಸಂಪರ್ಕಗೊಂಡಿದೆ", "External": "ಹೊರಗಿನ", "FailedLoginAttemptWithUserName": "ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ ಸಂಖ್ಯೆ {0}", "Favorites": "ಮೆಚ್ಚಿನವುಗಳು", @@ -31,22 +25,11 @@ "Forced": "ಬಲವಂತವಾಗಿ", "Genres": "ಪ್ರಕಾರಗಳು", "HeaderContinueWatching": "ನೋಡುವುದನ್ನು ಮುಂದುವರಿಸಿ", - "HeaderFavoriteAlbums": "ಮೆಚ್ಚಿನ ಸಂಪುಟಗಳು", - "HeaderFavoriteArtists": "ಮೆಚ್ಚಿನ ಕಲಾವಿದರು", "HeaderFavoriteShows": "ಮೆಚ್ಚಿನ ಪ್ರದರ್ಶನಗಳು", - "HeaderFavoriteSongs": "ಮೆಚ್ಚಿನ ಹಾಡುಗಳು", "HeaderLiveTV": "ನೇರ ದೂರದರ್ಶನ", "HeaderNextUp": "ಮುಂದೆ", - "HeaderRecordingGroups": "ರೆಕಾರ್ಡಿಂಗ್ ಗುಂಪುಗಳು", - "MessageApplicationUpdated": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", - "CameraImageUploadedFrom": "ಹೊಸ ಕ್ಯಾಮರಾ ಚಿತ್ರವನ್ನು {0} ನಿಂದ ಅಪ್ಲೋಡ್ ಮಾಡಲಾಗಿದೆ", - "Channels": "ಮೂಲಗಳು", - "HeaderAlbumArtists": "ಸಂಪುಟ ಕಲಾವಿದರು", "HeaderFavoriteEpisodes": "ಮೆಚ್ಚಿನ ಸಂಚಿಕೆಗಳು", "HearingImpaired": "ಮೂಗ", - "ItemAddedWithName": "{0} ಅನ್ನು ಸಂಕಲನಕ್ಕೆ ಸೇರಿಸಲಾಗಿದೆ", - "MessageApplicationUpdatedTo": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಅನ್ನು {0} ಗೆ ನವೀಕರಿಸಲಾಗಿದೆ", - "MessageNamedServerConfigurationUpdatedWithValue": "ಸರ್ವರ್ ಕಾನ್ಫಿಗರೇಶನ್ ವಿಭಾಗ {0} ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", "NewVersionIsAvailable": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ನ ಹೊಸ ಆವೃತ್ತಿಯು ಡೌನ್ಲೋಡ್ಗೆ ಲಭ್ಯವಿದೆ.", "NotificationOptionAudioPlayback": "ಆಡಿಯೋ ಪ್ಲೇಬ್ಯಾಕ್ ಪ್ರಾರಂಭವಾಗಿದೆ", "NotificationOptionCameraImageUploaded": "ಕ್ಯಾಮರಾ ಚಿತ್ರವನ್ನು ಅಪ್ಲೋಡ್ ಮಾಡಲಾಗಿದೆ", @@ -55,13 +38,10 @@ "NotificationOptionVideoPlaybackStopped": "ವೀಡಿಯೊ ಪ್ಲೇಬ್ಯಾಕ್ ನಿಲ್ಲಿಸಲಾಗಿದೆ", "PluginUninstalledWithName": "{0} ಅನ್ನು ಅನ್ಇನ್ಸ್ಟಾಲ್ ಮಾಡಲಾಗಿದೆ", "ScheduledTaskFailedWithName": "{0} ವಿಫಲವಾಗಿದೆ", - "ScheduledTaskStartedWithName": "{0} ಪ್ರಾರಂಭವಾಯಿತು", - "ServerNameNeedsToBeRestarted": "{0} ಅನ್ನು ಮರುಪ್ರಾರಂಭಿಸಬೇಕಾಗಿದೆ", "UserCreatedWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ರಚಿಸಲಾಗಿದೆ", "UserLockedOutWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ಲಾಕ್ ಮಾಡಲಾಗಿದೆ", "UserOnlineFromDevice": "{1} ನಿಂದ {0} ಆನ್ಲೈನ್ನಲ್ಲಿದೆ", "UserPasswordChangedWithName": "{0} ಬಳಕೆದಾರರಿಗಾಗಿ ಪಾಸ್ವರ್ಡ್ ಅನ್ನು ಬದಲಾಯಿಸಲಾಗಿದೆ", - "UserPolicyUpdatedWithName": "ಬಳಕೆದಾರರ ನೀತಿಯನ್ನು {0} ಗೆ ನವೀಕರಿಸಲಾಗಿದೆ", "UserStartedPlayingItemWithValues": "{2} ರಂದು {0} ಆಡುತ್ತಿದೆ {1}", "UserStoppedPlayingItemWithValues": "{0} ಅವರು {1} ಅನ್ನು {2} ನಲ್ಲಿ ಆಡುವುದನ್ನು ಮುಗಿಸಿದ್ದಾರೆ", "VersionNumber": "ಆವೃತ್ತಿ {0}", @@ -76,23 +56,17 @@ "TaskCleanTranscodeDescription": "ಒಂದು ದಿನಕ್ಕಿಂತ ಹಳೆಯದಾದ ಟ್ರಾನ್ಸ್ಕೋಡ್ ಫೈಲ್ಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", "TaskDownloadMissingSubtitles": "ಕಾಣೆಯಾದ ಉಪಶೀರ್ಷಿಕೆಗಳನ್ನು ಡೌನ್ಲೋಡ್ ಮಾಡಿ", "Shows": "ಧಾರವಾಹಿಗಳು", - "Songs": "ಹಾಡುಗಳು", "StartupEmbyServerIsLoading": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಲೋಡ್ ಆಗುತ್ತಿದೆ. ದಯವಿಟ್ಟು ಸ್ವಲ್ಪ ಸಮಯದ ನಂತರ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.", "UserDeletedWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ಅಳಿಸಲಾಗಿದೆ", "UserDownloadingItemWithValues": "{0} ಡೌನ್ಲೋಡ್ ಆಗುತ್ತಿದೆ {1}", "SubtitleDownloadFailureFromForItem": "ಉಪಶೀರ್ಷಿಕೆಗಳು {0} ನಿಂದ {1} ಗಾಗಿ ಡೌನ್ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿವೆ", - "Sync": "ಹೊಂದಿಕೆ", - "System": "ವ್ಯವಸ್ಥೆ", "TvShows": "ದೂರದರ್ಶನ ಕಾರ್ಯಕ್ರಮಗಳು", "Undefined": "ವ್ಯಾಖ್ಯಾನಿಸಲಾಗಿಲ್ಲ", - "User": "ಬಳಕೆದಾರ", "HomeVideos": "ಮುಖಪುಟ ವೀಡಿಯೊಗಳು", "Inherit": "ಪಾರಂಪರ್ಯವಾಗಿ", - "ItemRemovedWithName": "{0} ಅನ್ನು ಸಂಕಲನದಿಂದ ತೆಗೆದುಹಾಕಲಾಗಿದೆ", "LabelIpAddressValue": "IP ವಿಳಾಸ: {0}", "LabelRunningTimeValue": "ಅವಧಿ: {0}", "Latest": "ಹೊಸದಾದ", - "MessageServerConfigurationUpdated": "ಸರ್ವರ್ ಕಾನ್ಫಿಗರೇಶನ್ ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", "MixedContent": "ಮಿಶ್ರ ವಿಷಯ", "Movies": "ಚಲನಚಿತ್ರಗಳು", "Music": "ಸಂಗೀತ", @@ -106,17 +80,14 @@ "NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ", "NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ", "NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", - "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", + "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ", "NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ", "NotificationOptionVideoPlayback": "ವೀಡಿಯೊ ಪ್ಲೇಬ್ಯಾಕ್ ಪ್ರಾರಂಭವಾಗಿದೆ", "Photos": "ಚಿತ್ರಗಳು", - "Playlists": "ಪ್ಲೇಪಟ್ಟಿಗಳು", - "Plugin": "ಪ್ಲಗಿನ್", "PluginInstalledWithName": "{0} ಅನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", "PluginUpdatedWithName": "{0} ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", - "ProviderValue": "ಒದಗಿಸುವವರು: {0}", "TaskCleanLogs": "ಕ್ಲೀನ್ ಲಾಗ್ ಡೈರೆಕ್ಟರಿ", "TaskRefreshPeople": "ಜನರನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಿ", "TaskRefreshPeopleDescription": "ನಿಮ್ಮ ಮಾಧ್ಯಮ ಲೈಬ್ರರಿಯಲ್ಲಿ ನಟರು ಮತ್ತು ನಿರ್ದೇಶಕರಿಗಾಗಿ ಮೆಟಾಡೇಟಾವನ್ನು ನವೀಕರಿಸಿ.", diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 0451dcc9f0..a210125d34 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -1,41 +1,24 @@ { - "Albums": "앨범", "AppDeviceValues": "앱: {0}, 장치: {1}", - "Application": "애플리케이션", "Artists": "아티스트", "AuthenticationSucceededWithUserName": "{0} 사용자가 성공적으로 인증됨", "Books": "도서", - "CameraImageUploadedFrom": "{0}에서 새로운 카메라 이미지가 업로드됨", - "Channels": "채널", "ChapterNameValue": "챕터 {0}", "Collections": "컬렉션", - "DeviceOfflineWithName": "{0}의 연결 끊김", - "DeviceOnlineWithName": "{0}이(가) 연결됨", "FailedLoginAttemptWithUserName": "{0}에서 로그인 실패", "Favorites": "즐겨찾기", "Folders": "폴더", "Genres": "장르", - "HeaderAlbumArtists": "앨범 음악가", "HeaderContinueWatching": "계속 시청하기", - "HeaderFavoriteAlbums": "즐겨찾는 앨범", - "HeaderFavoriteArtists": "즐겨찾는 아티스트", "HeaderFavoriteEpisodes": "즐겨찾는 에피소드", "HeaderFavoriteShows": "즐겨찾는 쇼", - "HeaderFavoriteSongs": "즐겨찾는 노래", "HeaderLiveTV": "실시간 TV", "HeaderNextUp": "다음으로", - "HeaderRecordingGroups": "녹화 그룹", "HomeVideos": "홈 비디오", "Inherit": "상속", - "ItemAddedWithName": "{0}가 라이브러리에 추가되었습니다", - "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨", "LabelIpAddressValue": "IP 주소: {0}", "LabelRunningTimeValue": "상영 시간: {0}", "Latest": "최근", - "MessageApplicationUpdated": "Jellyfin 서버가 업데이트되었습니다", - "MessageApplicationUpdatedTo": "Jellyfin 서버가 {0}로 업데이트되었습니다", - "MessageNamedServerConfigurationUpdatedWithValue": "서버 환경 설정 {0} 섹션이 업데이트되었습니다", - "MessageServerConfigurationUpdated": "서버 환경 설정이 업데이트되었습니다", "MixedContent": "혼합 콘텐츠", "Movies": "영화", "Music": "음악", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "비디오 재생 시작됨", "NotificationOptionVideoPlaybackStopped": "비디오 재생 중지됨", "Photos": "사진", - "Playlists": "재생목록", - "Plugin": "플러그인", "PluginInstalledWithName": "{0} 설치됨", "PluginUninstalledWithName": "{0} 제거됨", "PluginUpdatedWithName": "{0} 업데이트됨", - "ProviderValue": "제공자: {0}", "ScheduledTaskFailedWithName": "{0} 실패", - "ScheduledTaskStartedWithName": "{0} 시작", - "ServerNameNeedsToBeRestarted": "{0}를 재시작해야합니다", "Shows": "시리즈", - "Songs": "노래", "StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.", "SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다", - "Sync": "동기화", - "System": "시스템", "TvShows": "TV 쇼", - "User": "사용자", "UserCreatedWithName": "사용자 {0} 생성됨", "UserDeletedWithName": "사용자 {0} 삭제됨", "UserDownloadingItemWithValues": "{0} 사용자가 {1} 다운로드 중", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} 사용자의 {1}에서 연결이 끊김", "UserOnlineFromDevice": "{0} 사용자가 {1}에서 접속함", "UserPasswordChangedWithName": "{0} 사용자 비밀번호 변경됨", - "UserPolicyUpdatedWithName": "{0} 사용자 정책 업데이트됨", "UserStartedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생 중", "UserStoppedPlayingItemWithValues": "{0} 사용자의 {2}에서 {1} 재생을 마침", - "ValueHasBeenAddedToLibrary": "{0}가 미디어 라이브러리에 추가되었습니다", - "ValueSpecialEpisodeName": "스페셜 - {0}", "VersionNumber": "버전 {0}", "TasksApplicationCategory": "어플리케이션", "TasksMaintenanceCategory": "유지 보수", @@ -135,5 +106,7 @@ "TaskDownloadMissingLyrics": "누락된 가사 다운로드", "TaskDownloadMissingLyricsDescription": "가사 다운로드", "CleanupUserDataTask": "사용자 데이터 정리 작업", - "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다." + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", + "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", + "Original": "원본" } diff --git a/Emby.Server.Implementations/Localization/Core/kw.json b/Emby.Server.Implementations/Localization/Core/kw.json index 613d531103..fc2e189e7f 100644 --- a/Emby.Server.Implementations/Localization/Core/kw.json +++ b/Emby.Server.Implementations/Localization/Core/kw.json @@ -1,21 +1,13 @@ { "Collections": "Kuntellow", - "DeviceOfflineWithName": "{0} re anjunyas", "External": "A-ves", "Folders": "Plegellow", - "HeaderFavoriteAlbums": "Albomow Drudh", - "HeaderFavoriteArtists": "Artydhyon Drudh", "HeaderFavoriteEpisodes": "Towlennow Drudh", - "HeaderFavoriteSongs": "Kanow Drudh", - "HeaderRecordingGroups": "Bagasow Rekordya", "HearingImpaired": "Klewans Aperys", "HomeVideos": "Gwydhyow Tre", "Inherit": "Herya", "LabelRunningTimeValue": "Prys ow ponya: {0}", "Latest": "Diwettha", - "MessageApplicationUpdated": "Servell Jellyfin re beu nowedhys", - "MessageApplicationUpdatedTo": "Servell Jellyfin re beu nowedhys dhe {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Rann dewisyans servell {0} re beu nowedhys", "MixedContent": "Dalgh kemmyskys", "Movies": "Fylmow", "MusicVideos": "Gwydhyow Ilow", @@ -25,23 +17,16 @@ "NotificationOptionPluginError": "Defowt ystynnans", "NotificationOptionPluginUninstalled": "Ystynnans anynstallys", "NotificationOptionPluginUpdateInstalled": "Nowedheans ystynnans ynstallys", - "Application": "Gweythres", "Favorites": "Moyha Kerys", "Forced": "Konstrynys", - "Albums": "Albomow", "Books": "Lyvrow", - "Channels": "Kanolyow", "AppDeviceValues": "App: {0}, Devis: {1}", "Artists": "Artyhdyon", - "HeaderAlbumArtists": "Albom artydhyon", "HeaderNextUp": "Nessa", - "CameraImageUploadedFrom": "Skeusen kamera nowydh re beu ughkargys a-dhyworth {0}", "ChapterNameValue": "Chaptra {0}", "FailedLoginAttemptWithUserName": "Assay omgelm fyllys a-dhyworth {0}", "AuthenticationSucceededWithUserName": "{0} omgelmys yn sewen", "Default": "Defowt", - "DeviceOnlineWithName": "{0} yw junys", - "ItemRemovedWithName": "{0} a veu dileys a-dhyworth an lyverva", "LabelIpAddressValue": "Trigva PK: {)}", "Music": "Ilow", "HeaderContinueWatching": "Pesya Ow Kweles", @@ -50,8 +35,6 @@ "NotificationOptionCameraImageUploaded": "Skeusen kamera ughkargys", "HeaderFavoriteShows": "Diskwedhyansow Drudh", "HeaderLiveTV": "PW Yn Fyw", - "MessageServerConfigurationUpdated": "Dewisyans servell re beu nowedhys", - "ItemAddedWithName": "{0} a veu keworrys dhe'n lyverva", "NameInstallFailed": "{0} ynstallyans fyllys", "NotificationOptionNewLibraryContent": "Dalgh nowydh keworrys", "NewVersionIsAvailable": "Yma versyon nowydh a Servell Jellyfin neb yw kavadow rag iskarga.", @@ -62,8 +45,6 @@ "NotificationOptionServerRestartRequired": "Dastalleth servell yw res", "StartupEmbyServerIsLoading": "Yma Servell Jellyfin ow kargya. Assay arta yn berr mar pleg.", "SubtitleDownloadFailureFromForItem": "Istitlow a fyllis iskarga a-dhyworth {0] rag {1}", - "System": "Kevreyth", - "User": "Devnydhyer", "UserDeletedWithName": "Devnydhyer {0} re beu dileys", "UserLockedOutWithName": "Devnydhyer {0} re beu alhwedhys yn-mes", "UserStoppedPlayingItemWithValues": "{0} re worfennas gwari {1} war {2}", @@ -71,21 +52,15 @@ "UserOnlineFromDevice": "{0} yw warlinen a-dhyworth {1}", "NotificationOptionUserLockedOut": "Devnydhyer yw alhwedhys yn-mes", "Photos": "Skeusennow", - "Playlists": "Rolyow-gwari", - "Plugin": "Ystynnans", "PluginInstalledWithName": "{0} a veu ynstallys", - "UserPolicyUpdatedWithName": "Polici devnydhyer re beu nowedhys rag {0}", "PluginUpdatedWithName": "{0} a veu nowedhys", "ScheduledTaskFailedWithName": "{0} a fyllis", - "Songs": "Kanow", - "Sync": "Kesseni", "TvShows": "Towlennow PW", "Undefined": "Anstyrys", "UserCreatedWithName": "Devnydhyer {0} re beu gwruthys", "UserDownloadingItemWithValues": "Yma {0} owth iskarga {1}", "UserPasswordChangedWithName": "Ger-tremena re beu chanjys rag devnydhyer {0}", "UserStartedPlayingItemWithValues": "Yma {0} ow kwari {1} war {2}", - "ValueHasBeenAddedToLibrary": "{0} re beu keworrys dhe'th lyverva media", "VersionNumber": "Versyon {0}", "TasksLibraryCategory": "Lyverva", "TaskCleanActivityLog": "Glanhe Kovlyver Gwrians", @@ -96,10 +71,6 @@ "NotificationOptionVideoPlayback": "Gwareans gwydhyow yw dallethys", "PluginUninstalledWithName": "{0} a veu anynstallys", "NotificationOptionTaskFailed": "Defowt oberen towlennys", - "ProviderValue": "Provier: {0}", - "ScheduledTaskStartedWithName": "{0} a dhallathas", - "ServerNameNeedsToBeRestarted": "Yma edhom dhe {0} a vos dastallathys", - "ValueSpecialEpisodeName": "Arbennik - {0}", "TasksMaintenanceCategory": "Mentons", "TasksApplicationCategory": "Gweythres", "TasksChannelsCategory": "Kanolyow Kesrosweyth", diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json index 2afec05dbd..e94709b083 100644 --- a/Emby.Server.Implementations/Localization/Core/lb.json +++ b/Emby.Server.Implementations/Localization/Core/lb.json @@ -1,38 +1,24 @@ { - "Albums": "Alben", - "Application": "Applikatioun", "Artists": "Kënschtler", "Books": "Bicher", - "Channels": "Kanäl", "Collections": "Kollektiounen", "Default": "Standard", "ChapterNameValue": "Kapitel {0}", - "DeviceOnlineWithName": "{0} ass Online", - "DeviceOfflineWithName": "{0} ass Offline", "External": "Extern", "Favorites": "Favoritten", "Folders": "Dossieren", "Forced": "Forcéiert", - "HeaderAlbumArtists": "Album Kënschtler", - "HeaderFavoriteAlbums": "Léifsten Alben", - "HeaderFavoriteArtists": "Léifsten Kënschtler", "HeaderFavoriteEpisodes": "Léifsten Episoden", "HeaderFavoriteShows": "Léifsten Shows", - "HeaderFavoriteSongs": "Léifsten Lidder", "Genres": "Generen", "HeaderContinueWatching": "Weider kucken", "Inherit": "Iwwerhuelen", "HeaderNextUp": "Als Nächst", - "HeaderRecordingGroups": "Opname Gruppen", "HearingImpaired": "Daaf", "HomeVideos": "Amateur Videoen", - "ItemRemovedWithName": "Element ewech geholl: {0}", "LabelIpAddressValue": "IP Adress: {0}", "LabelRunningTimeValue": "Lafzäit: {0}", "Latest": "Dat Aktuellst", - "MessageApplicationUpdatedTo": "Jellyfin Server aktualiséiert op {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server Konfiguratiounssektioun {0} aktualiséiert", - "MessageServerConfigurationUpdated": "Server Konfiguratioun aktualiséiert", "Movies": "Filmer", "Music": "Musek", "NameInstallFailed": "{0} Installatioun net gelongen", @@ -55,19 +41,11 @@ "NotificationOptionUserLockedOut": "Benotzer Gesperrt", "NotificationOptionVideoPlaybackStopped": "Video ofspillen gestoppt", "NotificationOptionVideoPlayback": "Video ofspillen gestartet", - "Plugin": "Plugin", "PluginUninstalledWithName": "{0} desinstalléiert", "PluginUpdatedWithName": "{0} aktualiséiert", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "Aufgab: {0} net gelongen", - "Playlists": "Playlëschten", "Shows": "Shows", - "Songs": "Lidder", - "ServerNameNeedsToBeRestarted": "{0} muss nei gestart ginn", "StartupEmbyServerIsLoading": "Jellyfin Server luedt. Probéier méi spéit nach eng Kéier.", - "Sync": "Synchroniséieren", - "System": "System", - "User": "Benotzer", "TvShows": "TV Shows", "Undefined": "Net definéiert", "UserCreatedWithName": "Benotzer {0} erstellt", @@ -76,13 +54,10 @@ "UserLockedOutWithName": "Benotzer {0} gesperrt", "UserOnlineFromDevice": "{0} Benotzer Online um Gerät {1}", "UserPasswordChangedWithName": "Benotzer Passwuert geännert fir {0}", - "UserPolicyUpdatedWithName": "Benotzer Politik aktualiséiert fir: {0}", "UserStartedPlayingItemWithValues": "{0} spillt {1} op {2} oof", - "ValueHasBeenAddedToLibrary": "{0} der Bibliothéik bäigefüügt", "VersionNumber": "Versioun {0}", "TasksMaintenanceCategory": "Ënnerhalt", "TasksLibraryCategory": "Bibliothéik", - "ValueSpecialEpisodeName": "Spezial-Episodenumm", "TasksChannelsCategory": "Internet Kanäl", "TaskCleanActivityLog": "Aktivitéits Log botzen", "TaskCleanActivityLogDescription": "Läscht Aktivitéitslogs méi al wéi konfiguréiert.", @@ -106,18 +81,14 @@ "TaskKeyframeExtractor": "Schlësselbild Extrakter", "TaskExtractMediaSegments": "Mediesegment-Scan", "NewVersionIsAvailable": "Nei Versioun fir Jellyfin Server ass verfügbar.", - "CameraImageUploadedFrom": "En neit Kamera Bild gouf vu {0} eropgelueden", "PluginInstalledWithName": "{0} installéiert", "TaskMoveTrickplayImagesDescription": "Verschëfft existent Trickplay-Dateien no de Bibliothéik-Astellungen.", "AppDeviceValues": "App: {0}, Geräter: {1}", "FailedLoginAttemptWithUserName": "Net Gelongen Umeldung {0}", "HeaderLiveTV": "LiveTV", - "ItemAddedWithName": "Element derbäi gesat: {0}", "NotificationOptionServerRestartRequired": "Server Restart Erfuerderlech", - "ScheduledTaskStartedWithName": "Aufgab: {0} gestart", "AuthenticationSucceededWithUserName": "{0} Authentifikatioun gelongen", "MixedContent": "Gemëschten Inhalt", - "MessageApplicationUpdated": "Jellyfin Server Aktualiséiert", "SubtitleDownloadFailureFromForItem": "Ënnertitel Download Feeler vun {0} fir {1}", "TaskCleanLogsDescription": "Läscht Log-Dateien, déi méi al wéi {0} Deeg sinn.", "TaskUpdatePlugins": "Plugins aktualiséieren", diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index daff719ea7..ed26004a43 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -1,41 +1,24 @@ { - "Albums": "Albumai", "AppDeviceValues": "Programa: {0}, Įrenginys: {1}", - "Application": "Programėlė", "Artists": "Atlikėjai", "AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota", "Books": "Knygos", - "CameraImageUploadedFrom": "Nauja nuotrauka įkelta iš kameros {0}", - "Channels": "Kanalai", "ChapterNameValue": "Scena{0}", "Collections": "Rinkiniai", - "DeviceOfflineWithName": "{0} buvo atjungtas", - "DeviceOnlineWithName": "{0} prisijungęs", "FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti", "Favorites": "Mėgstami", "Folders": "Katalogai", "Genres": "Žanrai", - "HeaderAlbumArtists": "Albumo atlikėjai", "HeaderContinueWatching": "Žiūrėti toliau", - "HeaderFavoriteAlbums": "Mėgstami albumai", - "HeaderFavoriteArtists": "Mėgstami atlikėjai", "HeaderFavoriteEpisodes": "Mėgstamiausios serijos", "HeaderFavoriteShows": "Mėgstamiausios TV Laidos", - "HeaderFavoriteSongs": "Mėgstamos Dainos", "HeaderLiveTV": "Tiesioginė TV", "HeaderNextUp": "Toliau", - "HeaderRecordingGroups": "Įrašų grupės", "HomeVideos": "Namų vaizdo įrašai", "Inherit": "Paveldėti", - "ItemAddedWithName": "{0} - buvo įkeltas į biblioteką", - "ItemRemovedWithName": "{0} - buvo pašalinta iš bibliotekos", "LabelIpAddressValue": "IP adresas: {0}", "LabelRunningTimeValue": "Trukmė: {0}", "Latest": "Naujausi", - "MessageApplicationUpdated": "\"Jellyfin Server\" atnaujintas", - "MessageApplicationUpdatedTo": "\"Jellyfin Server\" buvo atnaujinta iki {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverio nustatymai (skyrius {0}) buvo atnaujinti", - "MessageServerConfigurationUpdated": "Serverio nustatymai buvo atnaujinti", "MixedContent": "Mišrus turinys", "Movies": "Filmai", "Music": "Muzika", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Vaizdo įrašo atkūrimas pradėtas", "NotificationOptionVideoPlaybackStopped": "Vaizdo įrašo atkūrimas sustabdytas", "Photos": "Nuotraukos", - "Playlists": "Grojaraščiai", - "Plugin": "Įskiepis", "PluginInstalledWithName": "{0} buvo įdiegtas", "PluginUninstalledWithName": "{0} buvo pašalintas", "PluginUpdatedWithName": "{0} buvo atnaujintas", - "ProviderValue": "Paslaugos tiekėjas: {0}", "ScheduledTaskFailedWithName": "{0} nepavyko", - "ScheduledTaskStartedWithName": "{0} paleista", - "ServerNameNeedsToBeRestarted": "{0} reikia iš naujo paleisti", "Shows": "Laidos", - "Songs": "Kūriniai", "StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.", "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", - "Sync": "Sinchronizuoti", - "System": "Sistema", "TvShows": "TV laidos", - "User": "Naudotojas", "UserCreatedWithName": "Buvo sukurtas {0} naudotojas", "UserDeletedWithName": "Naudotojas {0} ištrintas", "UserDownloadingItemWithValues": "{0} siunčiasi {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}", "UserOnlineFromDevice": "{0} prisijungęs iš {1}", "UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}", - "UserPolicyUpdatedWithName": "Naudotojo {0} teisės buvo pakeistos", "UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}", "UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}", - "ValueHasBeenAddedToLibrary": "{0} pridėtas į mediateką", - "ValueSpecialEpisodeName": "Ypatingų - {0}", "VersionNumber": "Versija {0}", "TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.", "TaskUpdatePlugins": "Atnaujinti įskieius", diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 1083e3c299..4a1b248e76 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -1,15 +1,10 @@ { - "ServerNameNeedsToBeRestarted": "{0} ir vajadzīgs restarts", "NotificationOptionTaskFailed": "Plānota uzdevuma kļūme", - "HeaderRecordingGroups": "Ierakstu grupas", - "UserPolicyUpdatedWithName": "Lietotāju politika atjaunota priekš {0}", "SubtitleDownloadFailureFromForItem": "Subtitru lejupielāde no {0} priekš {1} neizdevās", "NotificationOptionVideoPlaybackStopped": "Video atskaņošana apturēta", "NotificationOptionVideoPlayback": "Video atskaņošana sākta", "NotificationOptionInstallationFailed": "Instalācija neizdevās", "AuthenticationSucceededWithUserName": "{0} veiksmīgi autentificējies", - "ValueSpecialEpisodeName": "Speciālais - {0}", - "ScheduledTaskStartedWithName": "{0} iesākts", "ScheduledTaskFailedWithName": "{0} neizdevās", "Photos": "Attēli", "NotificationOptionUserLockedOut": "Lietotājs bloķēts", @@ -17,7 +12,6 @@ "Inherit": "Pārmantot", "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", "VersionNumber": "Versija {0}", - "ValueHasBeenAddedToLibrary": "{0} tika pievienots jūsu multvides bibliotēkai", "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}", "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}", "UserPasswordChangedWithName": "Lietotāja {0} parole tika nomainīta", @@ -27,23 +21,16 @@ "UserDownloadingItemWithValues": "{0} lejupielādē {1}", "UserDeletedWithName": "Lietotājs {0} ir izdzēsts", "UserCreatedWithName": "Lietotājs {0} ir ticis izveidots", - "User": "Lietotājs", "TvShows": "TV raidījumi", - "Sync": "Sinhronizācija", - "System": "Sistēma", "StartupEmbyServerIsLoading": "Jellyfin Serveris lādējas. Lūdzu mēģiniet vēlreiz pēc brīža.", - "Songs": "Dziesmas", "Shows": "Šovi", "PluginUpdatedWithName": "{0} tika atjaunots", "PluginUninstalledWithName": "{0} tika noņemts", "PluginInstalledWithName": "{0} tika uzstādīts", - "Plugin": "Paplašinājums", - "Playlists": "Atskaņošanas saraksti", "MixedContent": "Jaukts saturs", "HomeVideos": "Mājas video", "HeaderNextUp": "Nākamais", "ChapterNameValue": "{0}. nodaļa", - "Application": "Lietotne", "NotificationOptionServerRestartRequired": "Nepieciešams servera restarts", "NotificationOptionPluginUpdateInstalled": "Paplašinājuma atjauninājums uzstādīts", "NotificationOptionPluginUninstalled": "Paplašinājums noņemts", @@ -62,35 +49,19 @@ "MusicVideos": "Mūzikas video", "Music": "Mūzika", "Movies": "Filmas", - "MessageServerConfigurationUpdated": "Servera konfigurācija ir tikusi atjaunota", - "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} tika atjaunota", - "MessageApplicationUpdatedTo": "Jellyfin Server ir ticis atjaunots uz {0}", - "MessageApplicationUpdated": "Jellyfin Server ir ticis atjaunots", "Latest": "Jaunākais", "LabelIpAddressValue": "IP adrese: {0}", - "ItemRemovedWithName": "{0} tika noņemts no bibliotēkas", - "ItemAddedWithName": "{0} tika pievienots bibliotēkai", "HeaderLiveTV": "Tiešraides TV", "HeaderContinueWatching": "Turpini skatīties", - "HeaderAlbumArtists": "Albumu izpildītāji", "Genres": "Žanri", "Folders": "Mapes", "Favorites": "Izlase", "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}", - "DeviceOnlineWithName": "Savienojums ar {0} ir izveidots", - "DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts", "Collections": "Kolekcijas", - "Channels": "Kanāli", - "CameraImageUploadedFrom": "Jauns kameras attēls tika augšupielādēts no {0}", "Books": "Grāmatas", "Artists": "Izpildītāji", - "Albums": "Albumi", - "ProviderValue": "Provider: {0}", - "HeaderFavoriteSongs": "Dziesmu izlase", "HeaderFavoriteShows": "Raidījumu izlase", "HeaderFavoriteEpisodes": "Sēriju izlase", - "HeaderFavoriteArtists": "Izpildītāju izlase", - "HeaderFavoriteAlbums": "Albumu izlase", "TaskCleanCacheDescription": "Nodzēš kešatmiņas datnes, kas vairs nav sistēmai vajadzīgas.", "TaskRefreshChapterImages": "Izvilkt nodaļu attēlus", "TasksApplicationCategory": "Lietotne", @@ -135,5 +106,6 @@ "TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus", "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", - "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas." + "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", + "Original": "Oriģināls" } diff --git a/Emby.Server.Implementations/Localization/Core/lzh.json b/Emby.Server.Implementations/Localization/Core/lzh.json index 9fb53e41d5..6b61755d12 100644 --- a/Emby.Server.Implementations/Localization/Core/lzh.json +++ b/Emby.Server.Implementations/Localization/Core/lzh.json @@ -1,10 +1,8 @@ { - "Albums": "辑册", "Artists": "艺人", "AuthenticationSucceededWithUserName": "{0} 授之权矣", "Books": "册", "Genres": "类", - "HeaderAlbumArtists": "辑者", "Favorites": "至爱", "Folders": "箧", "HeaderContinueWatching": "接目未竟" diff --git a/Emby.Server.Implementations/Localization/Core/mi.json b/Emby.Server.Implementations/Localization/Core/mi.json index 3b20abb369..74fae52dca 100644 --- a/Emby.Server.Implementations/Localization/Core/mi.json +++ b/Emby.Server.Implementations/Localization/Core/mi.json @@ -1,9 +1,15 @@ { - "Albums": "Pukaemi", "AppDeviceValues": "Taupānga: {0}, Pūrere: {1}", - "Application": "Taupānga", "Artists": "Kaiwaiata", "AuthenticationSucceededWithUserName": "{0} has been successfully authenticated", "Books": "Ngā pukapuka", - "CameraImageUploadedFrom": "Kua tuku ake he whakaahua kāmera hou mai i {0}" + "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/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index efef194e14..daf8112eef 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -1,11 +1,8 @@ { "ScheduledTaskFailedWithName": "{0} неуспешно", - "ProviderValue": "Провајдер: {0}", "PluginUpdatedWithName": "{0} беше надоградено", "PluginUninstalledWithName": "{0} беше успешно деинсталирано", "PluginInstalledWithName": "{0} беше успешно инсталирано", - "Plugin": "Додатоци", - "Playlists": "Плејлисти", "Photos": "Слики", "NotificationOptionVideoPlaybackStopped": "Видео стопирано", "NotificationOptionVideoPlayback": "Видео пуштено", @@ -31,49 +28,29 @@ "Music": "Музика", "Movies": "Филмови", "MixedContent": "Мешана содржина", - "MessageServerConfigurationUpdated": "Серверската конфигурација беше надградена", - "MessageNamedServerConfigurationUpdatedWithValue": "Секцијата на конфигурација на сервер {0} беше надоградена", - "MessageApplicationUpdatedTo": "Jellyfin беше надограден до {0}", - "MessageApplicationUpdated": "Jellyfin Серверот беше надограден", "Latest": "Последно", "LabelRunningTimeValue": "Време на работа: {0}", "LabelIpAddressValue": "ИП Адреса: {0}", - "ItemRemovedWithName": "{0} е избришано до Библиотеката", - "ItemAddedWithName": "{0} беше додадено во Библиотеката", "Inherit": "Следно", "HomeVideos": "Домашни Видеа", - "HeaderRecordingGroups": "Групи на снимање", "HeaderNextUp": "Следно", "HeaderLiveTV": "ТВ", - "HeaderFavoriteSongs": "Омилени Песни", "HeaderFavoriteShows": "Омилени Серии", "HeaderFavoriteEpisodes": "Омилени Епизоди", - "HeaderFavoriteArtists": "Омилени Изведувачи", - "HeaderFavoriteAlbums": "Омилени Албуми", "HeaderContinueWatching": "Продолжи со Гледање", - "HeaderAlbumArtists": "Изведувачи од Албуми", "Genres": "Жанрови", "Folders": "Папки", "Favorites": "Омилени", "FailedLoginAttemptWithUserName": "Неуспешен обид за најавување од {0}", - "DeviceOnlineWithName": "{0} е приклучен", - "DeviceOfflineWithName": "{0} се исклучи", "Collections": "Колекции", "ChapterNameValue": "Дел {0}", - "Channels": "Канали", - "CameraImageUploadedFrom": "Нова слика од камера беше поставена од {0}", "Books": "Книги", "AuthenticationSucceededWithUserName": "{0} успешно поврзан", "Artists": "Изведувачи", - "Application": "Апликација", "AppDeviceValues": "Апликација: {0}, Уред: {1}", - "Albums": "Албуми", "VersionNumber": "Верзија {0}", - "ValueSpecialEpisodeName": "Специјално - {0}", - "ValueHasBeenAddedToLibrary": "{0} е додадено во твојата библиотека", "UserStoppedPlayingItemWithValues": "{0} заврши со репродукција {1} во {2}", "UserStartedPlayingItemWithValues": "{0} пушти {1} на {2}", - "UserPolicyUpdatedWithName": "Полисата на користење беше надоградена за {0}", "UserPasswordChangedWithName": "Лозинката е сменета за корисникот {0}", "UserOnlineFromDevice": "{0} е приклучен од {1}", "UserOfflineFromDevice": "{0} е дисконектиран од {1}", @@ -81,16 +58,10 @@ "UserDownloadingItemWithValues": "{0} се спушта {1}", "UserDeletedWithName": "Корисникот {0} е избришан", "UserCreatedWithName": "Корисникот {0} е креиран", - "User": "Корисник", "TvShows": "ТВ Серии", - "System": "Систем", - "Sync": "Синхронизација", "SubtitleDownloadFailureFromForItem": "Преводот неуспешно се спушти од {0} за {1}", "StartupEmbyServerIsLoading": "Jellyfin Server се пушта. Ве молиме причекајте.", - "Songs": "Песни", "Shows": "Серии", - "ServerNameNeedsToBeRestarted": "{0} треба да се рестартира", - "ScheduledTaskStartedWithName": "{0} започна", "TaskRefreshChapterImages": "Извези Слики од Поглавје", "TaskCleanCacheDescription": "Ги брише кешираните фајлови што не се повеќе потребни од системот.", "TaskCleanCache": "Исчисти Го Кешот", diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index 5f098bccac..dbf2ed4648 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -1,32 +1,18 @@ { "AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}", - "Application": "അപ്ലിക്കേഷൻ", "AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു", - "CameraImageUploadedFrom": "{0} എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്ലോഡുചെയ്തു", "ChapterNameValue": "അധ്യായം {0}", - "DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു", - "DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു", "FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", "Forced": "നിർബന്ധിതമായി", - "HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ", - "HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ", "HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ", "HeaderFavoriteShows": "പ്രിയപ്പെട്ട ഷോകൾ", - "HeaderFavoriteSongs": "പ്രിയപ്പെട്ട ഗാനങ്ങൾ", "HeaderLiveTV": "തത്സമയ ടിവി", "HeaderNextUp": "അടുത്തത്", - "HeaderRecordingGroups": "ഗ്രൂപ്പുകൾ റെക്കോർഡുചെയ്യുന്നു", "HomeVideos": "ഹോം വീഡിയോകൾ", "Inherit": "അനന്തരാവകാശം", - "ItemAddedWithName": "{0} ലൈബ്രറിയിൽ ചേർത്തു", - "ItemRemovedWithName": "{0} ലൈബ്രറിയിൽ നിന്ന് നീക്കംചെയ്തു", "LabelIpAddressValue": "IP വിലാസം: {0}", "LabelRunningTimeValue": "പ്രവർത്തന സമയം: {0}", "Latest": "ഏറ്റവും പുതിയ", - "MessageApplicationUpdated": "ജെല്ലിഫിൻ സെർവർ അപ്ഡേറ്റുചെയ്തു", - "MessageApplicationUpdatedTo": "ജെല്ലിഫിൻ സെർവർ {0 to ലേക്ക് അപ്ഡേറ്റുചെയ്തു", - "MessageNamedServerConfigurationUpdatedWithValue": "സെർവർ കോൺഫിഗറേഷൻ വിഭാഗം {0 അപ്ഡേറ്റുചെയ്തു", - "MessageServerConfigurationUpdated": "സെർവർ കോൺഫിഗറേഷൻ അപ്ഡേറ്റുചെയ്തു", "MixedContent": "മിശ്രിത ഉള്ളടക്കം", "Music": "സംഗീതം", "MusicVideos": "സംഗീത വീഡിയോകൾ", @@ -50,20 +36,14 @@ "NotificationOptionUserLockedOut": "ഉപയോക്താവ് ലോക്ക് out ട്ട് ചെയ്തു", "NotificationOptionVideoPlayback": "വീഡിയോ പ്ലേബാക്ക് ആരംഭിച്ചു", "NotificationOptionVideoPlaybackStopped": "വീഡിയോ പ്ലേബാക്ക് നിർത്തി", - "Plugin": "പ്ലഗിൻ", "PluginInstalledWithName": "{0} ഇൻസ്റ്റാളുചെയ്തു", "PluginUninstalledWithName": "{0 un അൺഇൻസ്റ്റാൾ ചെയ്തു", "PluginUpdatedWithName": "{0} അപ്ഡേറ്റുചെയ്തു", - "ProviderValue": "ദാതാവ്: {0}", "ScheduledTaskFailedWithName": "{0} പരാജയപ്പെട്ടു", - "ScheduledTaskStartedWithName": "{0} ആരംഭിച്ചു", - "ServerNameNeedsToBeRestarted": "{0} പുനരാരംഭിക്കേണ്ടതുണ്ട്", "StartupEmbyServerIsLoading": "ജെല്ലിഫിൻ സെർവർ ലോഡുചെയ്യുന്നു. ഉടൻ തന്നെ വീണ്ടും ശ്രമിക്കുക.", "SubtitleDownloadFailureFromForItem": "സബ്ടൈറ്റിലുകൾ {1} ന് {0 from ൽ നിന്ന് ഡ download ൺലോഡ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", - "System": "സിസ്റ്റം", "TvShows": "ടിവി ഷോകൾ", "Undefined": "നിർവചിച്ചിട്ടില്ല", - "User": "ഉപയോക്താവ്", "UserCreatedWithName": "ഉപയോക്താവ് {0 created സൃഷ്ടിച്ചു", "UserDeletedWithName": "ഉപയോക്താവ് {0 deleted ഇല്ലാതാക്കി", "UserDownloadingItemWithValues": "{0} ഡൗൺലോഡുചെയ്യുന്നു {1}", @@ -71,10 +51,8 @@ "UserOfflineFromDevice": "{0} {1} ൽ നിന്ന് വിച്ഛേദിച്ചു", "UserOnlineFromDevice": "{0} {1} മുതൽ ഓൺലൈനിലാണ്", "UserPasswordChangedWithName": "{0} ഉപയോക്താവിനായി പാസ്വേഡ് മാറ്റി", - "UserPolicyUpdatedWithName": "{0} എന്നതിനായി ഉപയോക്തൃ നയം അപ്ഡേറ്റുചെയ്തു", "UserStartedPlayingItemWithValues": "{0} {2} ൽ {1} പ്ലേ ചെയ്യുന്നു", "UserStoppedPlayingItemWithValues": "{0} {2} ൽ {1 play കളിക്കുന്നത് പൂർത്തിയാക്കി", - "ValueHasBeenAddedToLibrary": "Media 0 your നിങ്ങളുടെ മീഡിയ ലൈബ്രറിയിലേക്ക് ചേർത്തു", "VersionNumber": "പതിപ്പ് {0}", "TasksMaintenanceCategory": "പരിപാലനം", "TasksLibraryCategory": "പുസ്തകശാല", @@ -100,16 +78,10 @@ "TaskRefreshChannelsDescription": "ഇന്റർനെറ്റ് ചാനൽ വിവരങ്ങൾ പുതുക്കുന്നു.", "TaskDownloadMissingSubtitles": "നഷ്ടമായ സബ്ടൈറ്റിലുകൾ ഡൗൺലോഡുചെയ്യുക", "TaskDownloadMissingSubtitlesDescription": "മെറ്റാഡാറ്റ കോൺഫിഗറേഷനെ അടിസ്ഥാനമാക്കി നഷ്ടമായ സബ്ടൈറ്റിലുകൾക്കായി ഇന്റർനെറ്റ് തിരയുന്നു.", - "ValueSpecialEpisodeName": "പ്രത്യേക - {0}", "Collections": "ശേഖരങ്ങൾ", "Folders": "ഫോൾഡറുകൾ", - "HeaderAlbumArtists": "കലാകാരന്റെ ആൽബം", - "Sync": "സമന്വയിപ്പിക്കുക", "Movies": "സിനിമകൾ", "Photos": "ഫോട്ടോകൾ", - "Albums": "ആൽബങ്ങൾ", - "Playlists": "പ്ലേലിസ്റ്റുകൾ", - "Songs": "ഗാനങ്ങൾ", "HeaderContinueWatching": "കാണുന്നത് തുടരുക", "Artists": "കലാകാരന്മാർ", "Shows": "ഷോകൾ", @@ -117,7 +89,6 @@ "Favorites": "പ്രിയപ്പെട്ടവ", "Books": "പുസ്തകങ്ങൾ", "Genres": "വിഭാഗങ്ങൾ", - "Channels": "ചാനലുകൾ", "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്കാൻ ചെയ്തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്തതിന് ശേഷം ഈ ടാസ്ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.", "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക", "HearingImpaired": "കേൾവി തകരാറുകൾ", @@ -127,5 +98,10 @@ "TaskAudioNormalization": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുക", "TaskAudioNormalizationDescription": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുന്ന ഡാറ്റയ്ക്കായി ഫയലുകൾ സ്കാൻ ചെയ്യുക.", "TaskRefreshTrickplayImages": "ട്രിക്ക് പ്ലേ ചിത്രങ്ങൾ സൃഷ്ടിക്കുക", - "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു." + "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു.", + "Original": "ഓറിജിനൽ", + "TaskDownloadMissingLyrics": "ഇല്ലാത്ത വരികൾ ഡൗൺലോഡ് ചെയ്യുക", + "TaskDownloadMissingLyricsDescription": "പാട്ടുകളുടെ വരികൾ ഡൗൺലോഡ് ചെയ്യുന്നു", + "TaskExtractMediaSegments": "മീഡിയ സെഗ്മെന്റ് സ്കാൻ", + "TaskExtractMediaSegmentsDescription": "മീഡിയസെഗ്മെന്റ് പ്രാപ്തമാക്കിയ പ്ലഗിനുകളിൽ നിന്ന് മീഡിയ സെഗ്മെന്റുകൾ എക്സ്ട്രാക്റ്റുചെയ്യുന്നു അല്ലെങ്കിൽ നേടുന്നു." } diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json index 63f4d0cef3..83caaf346f 100644 --- a/Emby.Server.Implementations/Localization/Core/mn.json +++ b/Emby.Server.Implementations/Localization/Core/mn.json @@ -2,15 +2,12 @@ "Books": "Номнууд", "HeaderNextUp": "Дараа нь", "HeaderContinueWatching": "Үргэлжлүүлэн үзэх", - "Songs": "Дуунууд", - "Playlists": "Тоглуулах жагсаалтууд", "Movies": "Кинонууд", "Latest": "Сүүлийн үеийн", "Genres": "Төрлүүд", "Favorites": "Дуртай", "Collections": "Цуглуулгууд", "Artists": "Уран бүтээлчид", - "Albums": "Дуут цомгууд", "TaskExtractMediaSegments": "Медиа сегмент шалга", "TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.", "TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх", @@ -21,7 +18,6 @@ "TaskKeyframeExtractor": "Түлхүүр кадр гаргагч", "TaskCleanCache": "Кэш санг цэвэрлэх", "NewVersionIsAvailable": "Jellyfin Server-н шинэ хувилбар татаж авахад нээлттэй боллоо.", - "MessageNamedServerConfigurationUpdatedWithValue": "Server-н {0}-р хэсгийн тохиргоо шинэчлэгдлээ", "NotificationOptionAudioPlaybackStopped": "Дууг зогсоов", "NotificationOptionNewLibraryContent": "Шинэ агуулга орлоо", "NotificationOptionServerRestartRequired": "Server-г дахин асаана уу", @@ -33,7 +29,6 @@ "SubtitleDownloadFailureFromForItem": "{0}-г {1}-д зориулсан хадмал орчуулгыг татаж авч чадсангүй", "TaskRefreshLibraryDescription": "Таны медиа санг шинэ файлуудын хувьд шалгаж, мета мэдээллийг шинэчилнэ.", "UserOfflineFromDevice": "{0}-г {1}-с салгалаа", - "ValueHasBeenAddedToLibrary": "{0}-г медиа сан руу нэмэгдлээ", "TaskRefreshPeopleDescription": "Таны медиа санд байгаа жүжигчид болон найруулагчдын мета мэдээллийг шинэчилнэ.", "TaskCleanTranscodeDescription": "Нэг өдрөөс илүү настай транскодлох файлуудыг устгана.", "TaskRefreshChannelsDescription": "Интернет сувгуудын мэдээллийг шинэчлэх.", @@ -51,36 +46,21 @@ "TaskRefreshChannels": "Сувгуудыг шинэчлэх", "TaskDownloadMissingSubtitles": "Алга болсон хадмал орчуулгыг татах", "External": "Гадны", - "HeaderFavoriteArtists": "Дуртай уран бүтээлчид", "HeaderFavoriteEpisodes": "Дуртай ангиуд", "HeaderFavoriteShows": "Дуртай нэвтрүүлэг", - "HeaderFavoriteSongs": "Дуртай дуу", "AppDeviceValues": "Aпп: {0}, Төхөөрөмж: {1}", - "Application": "Aпп", "AuthenticationSucceededWithUserName": "{0} амжилттай нэвтэрлээ", - "CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа", - "Channels": "Сувгууд", "ChapterNameValue": "{0}-р бүлэг", "Default": "Анхдагч", - "DeviceOfflineWithName": "{0}-н холболт саллаа", - "DeviceOnlineWithName": "{0} холбогдлоо", "FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй", "Folders": "Хавтасууд", "Forced": "Хүчээр", - "HeaderAlbumArtists": "Цомгийн уран бүтээлчид", - "HeaderFavoriteAlbums": "Дуртай цомгууд", "HeaderLiveTV": "Шууд ТВ", - "HeaderRecordingGroups": "Бичлэгийн бүлгүүд", "HearingImpaired": "Сонсголын бэрхшээлтэй", "HomeVideos": "Үндсэн дүрсүүд", "Inherit": "Уламжлах", - "ItemAddedWithName": "{0}-г санд нэмлээ", - "ItemRemovedWithName": "{0}-с сангаас хаслаа", "LabelIpAddressValue": "IP хаяг: {0}", "LabelRunningTimeValue": "Үргэлжлэх хугацаа: {0}", - "MessageApplicationUpdated": "Jellyfin Server шинэчлэгдлээ", - "MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ", - "MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ", "MixedContent": "Холимог агуулга", "Music": "Хөгжим", "MusicVideos": "Дууны клипүүд", @@ -99,28 +79,19 @@ "NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив", "NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв", "Photos": "Зургууд", - "Plugin": "Плагин", "PluginInstalledWithName": "{0}-г суулгалаа", "PluginUninstalledWithName": "{0}-г устгалаа", "PluginUpdatedWithName": "{0}-г шинэчиллээ", - "ProviderValue": "Нийлүүлэгч: {0}", - "ScheduledTaskStartedWithName": "{0}-г эхлүүлэв", - "ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу", "Shows": "Шоу", - "Sync": "Синхрончлох", - "System": "Систем", "TvShows": "ТВ нэвтрүүлгүүд", "Undefined": "Танисангүй", - "User": "Хэрэглэгч", "UserCreatedWithName": "Хэрэглэгч {0}-г үүсгэлээ", "UserDeletedWithName": "Хэрэглэгч {0}-г устгалаа", "UserDownloadingItemWithValues": "{0} нь {1}-г татаж байна", "UserLockedOutWithName": "Хэрэглэгч {0}-г түгжлээ", "UserOnlineFromDevice": "{0} нь {1}-тэй холбоотой байна", - "UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ", "UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна", "UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа", - "ValueSpecialEpisodeName": "Онцгой - {0}", "VersionNumber": "Хувилбар {0}", "TasksMaintenanceCategory": "Засвар", "TasksLibraryCategory": "Сан", diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json index 267222ecbe..f169d085d5 100644 --- a/Emby.Server.Implementations/Localization/Core/mr.json +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -1,22 +1,13 @@ { "Books": "पुस्तकं", "Artists": "संगीतकार", - "Albums": "अल्बम", - "Playlists": "प्लेलिस्ट", - "HeaderAlbumArtists": "अल्बम संगीतकार", "Folders": "फोल्डर", "HeaderFavoriteEpisodes": "आवडते भाग", - "HeaderFavoriteSongs": "आवडती गाणी", "Movies": "चित्रपट", - "HeaderFavoriteArtists": "आवडते संगीतकार", "Shows": "कार्यक्रम", - "HeaderFavoriteAlbums": "आवडते अल्बम", - "Channels": "वाहिन्या", - "ValueSpecialEpisodeName": "विशेष - {0}", "HeaderFavoriteShows": "आवडते कार्यक्रम", "Favorites": "आवडीचे", "HeaderNextUp": "यानंतर", - "Songs": "गाणी", "HeaderLiveTV": "लाइव्ह टीव्ही", "Genres": "जाँनरे", "Photos": "चित्र", @@ -34,10 +25,8 @@ "UserOnlineFromDevice": "{0} हे {1} येथून ऑनलाइन आहेत", "UserDeletedWithName": "प्रयोक्ता {0} काढून टाकण्यात आले आहे", "UserCreatedWithName": "प्रयोक्ता {0} बनवण्यात आले आहे", - "User": "प्रयोक्ता", "TvShows": "टीव्ही कार्यक्रम", "StartupEmbyServerIsLoading": "जेलिफिन सर्व्हर लोड होत आहे. कृपया थोड्या वेळात पुन्हा प्रयत्न करा.", - "Plugin": "प्लगइन", "NotificationOptionCameraImageUploaded": "कॅमेरा चित्र अपलोड केले आहे", "NotificationOptionApplicationUpdateInstalled": "अॅप्लिकेशन अपडेट इन्स्टॉल केले आहे", "NotificationOptionApplicationUpdateAvailable": "अॅप्लिकेशन अपडेट उपलब्ध आहे", @@ -46,16 +35,9 @@ "NameSeasonNumber": "सीझन {0}", "MusicVideos": "संगीत व्हिडीयो", "Music": "संगीत", - "MessageApplicationUpdatedTo": "जेलिफिन सर्व्हर अपडेट होऊन {0} आवृत्तीवर पोहोचला आहे", - "MessageApplicationUpdated": "जेलिफिन सर्व्हर अपडेट केला गेला आहे", "Latest": "नवीनतम", "LabelIpAddressValue": "आयपी पत्ता: {0}", - "ItemRemovedWithName": "{0} हे संग्रहालयातून काढून टाकण्यात आले", - "ItemAddedWithName": "{0} हे संग्रहालयात जोडले गेले", "HomeVideos": "घरचे व्हिडीयो", - "HeaderRecordingGroups": "रेकॉर्डिंग गट", - "CameraImageUploadedFrom": "एक नवीन कॅमेरा चित्र {0} येथून अपलोड केले आहे", - "Application": "अॅप्लिकेशन", "AppDeviceValues": "अॅप: {0}, यंत्र: {1}", "Collections": "संग्रह", "ChapterNameValue": "धडा {0}", @@ -70,18 +52,12 @@ "TaskRefreshChapterImagesDescription": "अध्याय असलेल्या व्हिडियोंसाठी थंबनेल चित्र बनवतो.", "TaskRefreshChapterImages": "अध्याय चित्र काढून घ्या", "TasksMaintenanceCategory": "देखरेख", - "ValueHasBeenAddedToLibrary": "{0} हे तुमच्या माध्यम संग्रहात जोडण्यात आले आहे", "UserStoppedPlayingItemWithValues": "{0} यांचं {2} वर {1} पूर्णपणे प्ले करून झालं आहे", "UserStartedPlayingItemWithValues": "{0} हे {2} वर {1} प्ले करत आहे", "UserDownloadingItemWithValues": "{0} हे {1} डाउनलोड करत आहे", - "System": "प्रणाली", "Undefined": "अव्याख्यात", - "Sync": "सिंक", - "ServerNameNeedsToBeRestarted": "{0} याला बंद करून पुन्हा सुरू करायची गरज आहे", "SubtitleDownloadFailureFromForItem": "{0} येथून {1} यासाठी उपशिर्षक डाउनलोड करण्यात अपयश", - "ScheduledTaskStartedWithName": "{0} सुरू झाले", "ScheduledTaskFailedWithName": "{0} अपयशी झाले", - "ProviderValue": "पुरवणारा: {0}", "PluginUpdatedWithName": "{0} अपडेट केले", "PluginUninstalledWithName": "{0} अनिन्स्टॉल केले", "PluginInstalledWithName": "{0} इन्स्टॉल केले", @@ -109,19 +85,14 @@ "TaskCleanCacheDescription": "सिस्टमला यापुढे आवश्यक नसलेल्या कॅशे फाइल्स हटवा.", "TaskCleanActivityLogDescription": "कॉन्फिगर केलेल्या वयापेक्षा जुन्या क्रियाकलाप लॉग एंट्री हटवा.", "TaskCleanActivityLog": "क्रियाकलाप लॉग साफ करा", - "UserPolicyUpdatedWithName": "{0} साठी वापरकर्ता धोरण अपडेट केले गेले आहे", "UserOfflineFromDevice": "{0} {1} वरून डिस्कनेक्ट झाला आहे", "UserLockedOutWithName": "वापरकर्ता {0} लॉक केले गेले आहे", "NotificationOptionUserLockedOut": "वापरकर्ता लॉक आउट", "NameInstallFailed": "{0} स्थापना अयशस्वी", - "MessageServerConfigurationUpdated": "सर्व्हर कॉन्फिगरेशन अद्यतनित केले आहे", - "MessageNamedServerConfigurationUpdatedWithValue": "सर्व्हर कॉन्फिगरेशन विभाग {0} अद्यतनित केला गेला आहे", "Inherit": "वारसा", "Forced": "सक्ती केली आहे", "FailedLoginAttemptWithUserName": "{0} कडून लॉगिन करण्याचा प्रयत्न अयशस्वी झाला", "External": "बाहेरचा", - "DeviceOnlineWithName": "{0} कनेक्ट झाले", - "DeviceOfflineWithName": "{0} डिस्कनेक्ट झाला आहे", "AuthenticationSucceededWithUserName": "{0} यशस्वीरित्या प्रमाणीकृत", "HearingImpaired": "कर्णबधीर", "TaskRefreshTrickplayImages": "ट्रिकप्ले प्रतिमा तयार करा", diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 743c14ac10..9fc61fadd5 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -1,41 +1,24 @@ { - "Albums": "Album", "AppDeviceValues": "Aplikasi: {0}, Peranti: {1}", - "Application": "Aplikasi", "Artists": "Artis", "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", "Books": "Buku", - "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", - "Channels": "Saluran", "ChapterNameValue": "Bab {0}", "Collections": "Koleksi", - "DeviceOfflineWithName": "{0} telah dinyahsambung", - "DeviceOnlineWithName": "{0} telah disambung", "FailedLoginAttemptWithUserName": "Percubaan gagal log masuk daripada {0}", "Favorites": "Kegemaran", "Folders": "Folder-folder", "Genres": "Genre-genre", - "HeaderAlbumArtists": "Album artis-artis", "HeaderContinueWatching": "Teruskan Menonton", - "HeaderFavoriteAlbums": "Album-album Kegemaran", - "HeaderFavoriteArtists": "Artis-artis Kegemaran", "HeaderFavoriteEpisodes": "Episod-episod Kegemaran", "HeaderFavoriteShows": "Rancangan-rancangan Kegemaran", - "HeaderFavoriteSongs": "Lagu-lagu Kegemaran", "HeaderLiveTV": "TV Siaran Langsung", "HeaderNextUp": "Seterusnya", - "HeaderRecordingGroups": "Kumpulan-kumpulan Rakaman", "HomeVideos": "Video Peribadi", "Inherit": "Warisi", - "ItemAddedWithName": "{0} telah ditambah ke dalam pustaka", - "ItemRemovedWithName": "{0} telah dibuang daripada pustaka", "LabelIpAddressValue": "Alamat IP: {0}", "LabelRunningTimeValue": "Masa berjalan: {0}", "Latest": "Terbaharu", - "MessageApplicationUpdated": "Pelayan Jellyfin telah dikemas kini", - "MessageApplicationUpdatedTo": "Pelayan Jellyfin telah dikemas kini ke {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurasi pelayan bahagian {0} telah dikemas kini", - "MessageServerConfigurationUpdated": "Konfigurasi pelayan telah dikemas kini", "MixedContent": "Kandungan campuran", "Movies": "Filem-filem", "Music": "Muzik", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Ulangmain video bermula", "NotificationOptionVideoPlaybackStopped": "Ulangmain video dihentikan", "Photos": "Gambar-gambar", - "Playlists": "Senarai ulangmain", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} telah dipasang", "PluginUninstalledWithName": "{0} telah dinyahpasang", "PluginUpdatedWithName": "{0} telah dikemaskini", - "ProviderValue": "Pembekal: {0}", "ScheduledTaskFailedWithName": "{0} gagal", - "ScheduledTaskStartedWithName": "{0} bermula", - "ServerNameNeedsToBeRestarted": "{0} perlu di ulangmula", "Shows": "Tayangan", - "Songs": "Lagu-lagu", "StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.", "SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}", - "Sync": "Segerak", - "System": "Sistem", "TvShows": "Tayangan TV", - "User": "Pengguna", "UserCreatedWithName": "Pengguna {0} telah diwujudkan", "UserDeletedWithName": "Pengguna {0} telah dipadamkan", "UserDownloadingItemWithValues": "{0} sedang memuat turun {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} telah terputus dari {1}", "UserOnlineFromDevice": "{0} berada dalam talian dari {1}", "UserPasswordChangedWithName": "Kata laluan telah ditukar bagi pengguna {0}", - "UserPolicyUpdatedWithName": "Dasar pengguna telah dikemas kini untuk {0}", "UserStartedPlayingItemWithValues": "{0} sedang dimainkan {1} pada {2}", "UserStoppedPlayingItemWithValues": "{0} telah tamat dimainkan {1} pada {2}", - "ValueHasBeenAddedToLibrary": "{0} telah ditambah ke media library anda", - "ValueSpecialEpisodeName": "Khas - {0}", "VersionNumber": "Versi {0}", "TaskCleanActivityLog": "Log Aktiviti Bersih", "TasksChannelsCategory": "Saluran Internet", diff --git a/Emby.Server.Implementations/Localization/Core/mt.json b/Emby.Server.Implementations/Localization/Core/mt.json index aa3029a262..e237a80b70 100644 --- a/Emby.Server.Implementations/Localization/Core/mt.json +++ b/Emby.Server.Implementations/Localization/Core/mt.json @@ -1,28 +1,18 @@ { - "Albums": "Albums", "AppDeviceValues": "Applikazzjoni: {0}, Device: {1}", - "Application": "Applikazzjoni", "Artists": "Artisti", "AuthenticationSucceededWithUserName": "{1} awtentikat b'suċċess", "Books": "Kotba", - "CameraImageUploadedFrom": "Ttella' ritratt ġdid tal-kamera minn {1}", - "Channels": "Stazzjonijiet", "ChapterNameValue": "Kapitlu {0}", "Collections": "Kollezzjonijiet", - "DeviceOfflineWithName": "{0} tneħħa", - "DeviceOnlineWithName": "{0} tqabbad", "External": "Estern", "FailedLoginAttemptWithUserName": "Attentat fallut ta' login minn {0}", "Favorites": "Favoriti", "Forced": "Sfurzat", "Genres": "Ġeneri", - "HeaderAlbumArtists": "Artisti tal-album", "HeaderContinueWatching": "Kompli Ara", - "HeaderFavoriteAlbums": "Albums Favoriti", - "HeaderFavoriteArtists": "Artisti Favoriti", "HeaderFavoriteEpisodes": "Episodji Favoriti", "HeaderFavoriteShows": "Programmi Favoriti", - "HeaderFavoriteSongs": "Kanzunetti Favoriti", "HeaderNextUp": "Li Jmiss", "SubtitleDownloadFailureFromForItem": "Is-sottotitli ma setgħux jitniżżlu minn {0} għal {1}", "UserPasswordChangedWithName": "Il-password għall-utent {0} inbidlet", @@ -32,18 +22,11 @@ "Default": "Standard", "Folders": "Folders", "HeaderLiveTV": "TV Dirett", - "HeaderRecordingGroups": "Gruppi ta' Rikordjar", "HearingImpaired": "Nuqqas ta' Smigħ", "HomeVideos": "Filmati Personali", "Inherit": "Jiret", - "ItemAddedWithName": "{0} żdied fil-librerija", - "ItemRemovedWithName": "{0} tneħħa mil-librerija", "LabelIpAddressValue": "Indirizz tal-IP: {0}", "Latest": "Tal-Aħħar", - "MessageApplicationUpdated": "Il-Jellyfin Server ġie aġġornat", - "MessageApplicationUpdatedTo": "Il-JellyFin Server ġie aġġornat għal {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Is-sezzjoni {0} tal-konfigurazzjoni tas-server ġiet aġġornata", - "MessageServerConfigurationUpdated": "Il-konfigurazzjoni tas-server ġiet aġġornata", "MixedContent": "Kontenut imħallat", "Movies": "Films", "Music": "Mużika", @@ -67,21 +50,12 @@ "NotificationOptionTaskFailed": "Falliment tat-task skedat", "NotificationOptionUserLockedOut": "Utent imsakkar", "Photos": "Ritratti", - "Playlists": "Playlists", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} ġie installat", "PluginUninstalledWithName": "{0} tneħħa", "PluginUpdatedWithName": "{0} ġie aġġornat", - "ProviderValue": "Fornitur: {0}", "ScheduledTaskFailedWithName": "{0} falla", - "ScheduledTaskStartedWithName": "{0} beda", - "ServerNameNeedsToBeRestarted": "{0} jeħtieġ restart", - "Songs": "Kanzunetti", "StartupEmbyServerIsLoading": "Jellyfin Server qed jillowdja. Jekk jogħġbok erġa' pprova ftit tal-ħin oħra.", - "Sync": "Sinkronizza", - "System": "Sistema", "Undefined": "Bla Definizzjoni", - "User": "Utent", "UserCreatedWithName": "L-utent {0} inħoloq", "UserDeletedWithName": "L-utent {0} tħassar", "UserDownloadingItemWithValues": "{0} qed iniżżel {1}", @@ -93,11 +67,8 @@ "NotificationOptionVideoPlaybackStopped": "Il-playback tal-filmat twaqqaf", "Shows": "Serje", "TvShows": "Serje Televiżivi", - "UserPolicyUpdatedWithName": "Il-politka tal-utent ġiet aġġornata għal {0}", "UserStartedPlayingItemWithValues": "{0} qed jara {1} fuq {2}", "UserStoppedPlayingItemWithValues": "{0} waqaf jara {1} fuq {2}", - "ValueHasBeenAddedToLibrary": "{0} ġie miżjud mal-librerija tal-midja tiegħek", - "ValueSpecialEpisodeName": "Speċjali - {0}", "VersionNumber": "Verżjoni {0}", "TasksMaintenanceCategory": "Manutenzjoni", "TasksLibraryCategory": "Librerija", diff --git a/Emby.Server.Implementations/Localization/Core/my.json b/Emby.Server.Implementations/Localization/Core/my.json index f2cd501076..47728adfc5 100644 --- a/Emby.Server.Implementations/Localization/Core/my.json +++ b/Emby.Server.Implementations/Localization/Core/my.json @@ -1,10 +1,8 @@ { "Default": "ပုံသေ", "Collections": "စုစည်းမှုများ", - "Channels": "တီဗွီလိုင်းများ", "Books": "စာအုပ်များ", "Artists": "အနုပညာရှင်များ", - "Albums": "သီချင်းအခွေများ", "TaskOptimizeDatabaseDescription": "ဒေတာဘေ့စ်ကို ကျစ်လစ်စေပြီး နေရာလွတ်များကို ဖြတ်တောက်ပေးသည်။ စာကြည့်တိုက်ကို စကင်န်ဖတ်ပြီးနောက် ဤလုပ်ငန်းကို လုပ်ဆောင်ခြင်း သို့မဟုတ် ဒေတာဘေ့စ်မွမ်းမံမှုများ စွမ်းဆောင်ရည်ကို မြှင့်တင်ပေးနိုင်သည်ဟု ရည်ညွှန်းသော အခြားပြောင်းလဲမှုများကို လုပ်ဆောင်ခြင်း။.", "TaskOptimizeDatabase": "ဒေတာဘေ့စ်ကို အကောင်းဆုံးဖြစ်အောင်လုပ်ပါ", "TaskDownloadMissingSubtitlesDescription": "မက်တာဒေတာ ဖွဲ့စည်းမှုပုံစံအပေါ် အခြေခံ၍ ပျောက်ဆုံးနေသော စာတန်းထိုးများအတွက် အင်တာနက်ကို ရှာဖွေသည်။", @@ -32,11 +30,8 @@ "TasksLibraryCategory": "မီဒီယာတိုက်", "TasksMaintenanceCategory": "ပြုပြင် ထိန်းသိမ်းခြင်း", "VersionNumber": "ဗားရှင်း {0}", - "ValueSpecialEpisodeName": "အထူး- {0}", - "ValueHasBeenAddedToLibrary": "{0} ကို သင့်မီဒီယာဒစ်ဂျစ်တိုက်သို့ ပေါင်းထည့်လိုက်ပါပြီ", "UserStoppedPlayingItemWithValues": "{0} သည် {1} ကို {2} တွင် ဖွင့်ပြီးပါပြီ", "UserStartedPlayingItemWithValues": "{0} သည် {1} ကို {2} တွင် ပြသနေသည်", - "UserPolicyUpdatedWithName": "{0} အတွက် အသုံးပြုသူမူဝါဒကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", "UserPasswordChangedWithName": "အသုံးပြုသူ {0} အတွက် စကားဝှက်ကို ပြောင်းထားသည်", "UserOnlineFromDevice": "{0} သည် {1} မှ အွန်လိုင်းဖြစ်သည်", "UserOfflineFromDevice": "{0} သည် {1} မှ ချိတ်ဆက်မှုပြတ်တောက်သွားသည်", @@ -44,24 +39,15 @@ "UserDownloadingItemWithValues": "{0} သည် {1} ကို ဒေါင်းလုဒ်လုပ်နေသည်", "UserDeletedWithName": "အသုံးပြုသူ {0} ကို ဖျက်လိုက်ပါပြီ", "UserCreatedWithName": "အသုံးပြုသူ {0} ကို ဖန်တီးပြီးပါပြီ", - "User": "အသုံးပြုသူ", "Undefined": "သတ်မှတ်မထားသော", "TvShows": "တီဗီ ဇာတ်လမ်းတွဲများ", - "System": "စနစ်", - "Sync": "ချိန်ကိုက်မည်", "SubtitleDownloadFailureFromForItem": "{1} အတွက် {0} မှ စာတန်းထိုးများ ဒေါင်းလုဒ်လုပ်ခြင်း မအောင်မြင်ပါ", "StartupEmbyServerIsLoading": "Jellyfin ဆာဗာကို အသင့်ပြင်နေပါသည်။ ခဏနေ ထပ်စမ်းကြည့်ပါ။", - "Songs": "သီချင်းများ", "Shows": "ဇာတ်လမ်းတွဲများ", - "ServerNameNeedsToBeRestarted": "{0} ကို ပြန်လည်စတင်ရန် လိုအပ်သည်", - "ScheduledTaskStartedWithName": "{0} စတင်ခဲ့သည်", "ScheduledTaskFailedWithName": "{0} မအောင်မြင်ပါ", - "ProviderValue": "ဝန်ဆောင်မှုပေးသူ- {0}", "PluginUpdatedWithName": "ပလပ်ခ်အင် {0} ကို အပ်ဒိတ်လုပ်ထားသည်", "PluginUninstalledWithName": "ပလပ်ခ်အင် {0} ကို ဖြုတ်လိုက်ပါပြီ", "PluginInstalledWithName": "ပလပ်ခ်အင် {0} ကို ထည့်သွင်းခဲ့သည်", - "Plugin": "ပလပ်အင်", - "Playlists": "အစီအစဉ်များ", "Photos": "ဓာတ်ပုံများ", "NotificationOptionVideoPlaybackStopped": "ဗီဒီယိုဖွင့်ခြင်း ရပ်သွားသည်", "NotificationOptionVideoPlayback": "ဗီဒီယိုဖွင့်ခြင်း စတင်ပါပြီ", @@ -87,38 +73,23 @@ "Music": "တေးဂီတ", "Movies": "ရုပ်ရှင်များ", "MixedContent": "ရောနှောပါဝင်မှု", - "MessageServerConfigurationUpdated": "ဆာဗာဖွဲ့စည်းပုံကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", - "MessageNamedServerConfigurationUpdatedWithValue": "ဆာဗာဖွဲ့စည်းပုံကဏ္ဍ {0} ကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", - "MessageApplicationUpdatedTo": "Jellyfin ဆာဗာကို {0} သို့ အပ်ဒိတ်လုပ်ထားသည်", - "MessageApplicationUpdated": "Jellyfin ဆာဗာကို အပ်ဒိတ်လုပ်ပြီးပါပြီ", "Latest": "နောက်ဆုံး", "LabelRunningTimeValue": "ကြာချိန် - {0}", "LabelIpAddressValue": "IP လိပ်စာ- {0}", - "ItemRemovedWithName": "{0} ကို ဒစ်ဂျစ်တိုက်မှ ဖယ်ရှားခဲ့သည်", - "ItemAddedWithName": "{0} ကို စာကြည့်တိုက်သို့ ထည့်ထားသည်", "Inherit": "ဆက်ခံ၍ လုပ်ဆောင်သည်", "HomeVideos": "ကိုယ်တိုင်ရိုက် ဗီဒီယိုများ", - "HeaderRecordingGroups": "အသံဖမ်းအဖွဲ့များ", "HeaderNextUp": "နောက်ထပ်", "HeaderLiveTV": "တီဗွီတိုက်ရိုက်", - "HeaderFavoriteSongs": "အကြိုက်ဆုံးသီချင်းများ", "HeaderFavoriteShows": "အကြိုက်ဆုံး ဇာတ်လမ်းတွဲများ", "HeaderFavoriteEpisodes": "အကြိုက်ဆုံး ဇာတ်လမ်းအပိုင်းများ", - "HeaderFavoriteArtists": "အကြိုက်ဆုံး အနုပညာရှင်များ", - "HeaderFavoriteAlbums": "အကြိုက်ဆုံး အယ်လ်ဘမ်များ", "HeaderContinueWatching": "ဆက်လက်ကြည့်ရှုပါ", - "HeaderAlbumArtists": "အယ်လ်ဘမ်အနုပညာရှင်များ", "Genres": "အမျိုးအစားများ", "Forced": "အတင်းအကြပ်", "Folders": "ဖိုလ်ဒါများ", "Favorites": "အကြိုက်ဆုံးများ", "FailedLoginAttemptWithUserName": "{0} မှ အကောင့်ဝင်ရန် မအောင်မြင်ပါ", - "DeviceOnlineWithName": "{0} ကို ချိတ်ဆက်ထားသည်", - "DeviceOfflineWithName": "{0} နှင့် အဆက်ပြတ်သွားပါပြီ", "ChapterNameValue": "အခန်း {0}", - "CameraImageUploadedFrom": "ကင်မရာပုံအသစ်ကို {0} မှ ထည့်သွင်းလိုက်သည်", "AuthenticationSucceededWithUserName": "{0} အောင်မြင်စွာ စစ်မှန်ကြောင်း အတည်ပြုပြီးပါပြီ", - "Application": "အပလီကေးရှင်း", "AppDeviceValues": "အက်ပ်- {0}၊ စက်- {1}", "External": "ပြင်ပ", "TaskKeyframeExtractorDescription": "ပိုမိုတိကျသည့် အိတ်ချ်အယ်လ်အက်စ် အစဉ်လိုက်ပြသမှုများ ဖန်တီးနိုင်ရန်အတွက် ဗီဒီယိုဖိုင်များမှ ကီးဖရိန်များကို ထုတ်နှုတ်ယူမည် ဖြစ်သည်။ ဤလုပ်ဆောင်မှုသည် အချိန်ကြာရှည်နိုင်သည်။", diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 351b238f9d..752b74ec1c 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -1,41 +1,24 @@ { - "Albums": "Album", "AppDeviceValues": "App: {0}, Enhet: {1}", - "Application": "Program", "Artists": "Artister", "AuthenticationSucceededWithUserName": "{0} har logget inn", "Books": "Bøker", - "CameraImageUploadedFrom": "Et nytt kamerabilde har blitt lastet opp fra {0}", - "Channels": "Kanaler", "ChapterNameValue": "Kapittel {0}", "Collections": "Samlinger", - "DeviceOfflineWithName": "{0} har koblet fra", - "DeviceOnlineWithName": "{0} er tilkoblet", "FailedLoginAttemptWithUserName": "Mislykket påloggingsforsøk fra {0}", "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Sjangre", - "HeaderAlbumArtists": "Albumartister", "HeaderContinueWatching": "Fortsett å se", - "HeaderFavoriteAlbums": "Favorittalbum", - "HeaderFavoriteArtists": "Favorittartister", "HeaderFavoriteEpisodes": "Favorittepisoder", "HeaderFavoriteShows": "Favorittserier", - "HeaderFavoriteSongs": "Favorittsanger", "HeaderLiveTV": "Direkte-TV", "HeaderNextUp": "Neste", - "HeaderRecordingGroups": "Opptaksgrupper", "HomeVideos": "Hjemmelagde filmer", "Inherit": "Arve", - "ItemAddedWithName": "{0} ble lagt til i biblioteket", - "ItemRemovedWithName": "{0} ble fjernet fra biblioteket", "LabelIpAddressValue": "IP-adresse: {0}", "LabelRunningTimeValue": "Spilletid: {0}", "Latest": "Siste", - "MessageApplicationUpdated": "Jellyfin-serveren har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin-serveren ble oppdatert til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfigurasjonsseksjon {0} har blitt oppdatert", - "MessageServerConfigurationUpdated": "Serverkonfigurasjon har blitt oppdatert", "MixedContent": "Blandet innhold", "Movies": "Filmer", "Music": "Musikk", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Videoavspilling startet", "NotificationOptionVideoPlaybackStopped": "Videoavspilling stoppet", "Photos": "Bilder", - "Playlists": "Spillelister", - "Plugin": "Programvareutvidelse", "PluginInstalledWithName": "{0} ble installert", "PluginUninstalledWithName": "{0} ble avinstallert", "PluginUpdatedWithName": "{0} ble oppdatert", - "ProviderValue": "Leverandør: {0}", "ScheduledTaskFailedWithName": "{0} mislykkes", - "ScheduledTaskStartedWithName": "{0} startet", - "ServerNameNeedsToBeRestarted": "{0} må startes på nytt", "Shows": "Serier", - "Songs": "Sanger", "StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.", "SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}", - "Sync": "Synkroniser", - "System": "System", "TvShows": "TV-serier", - "User": "Bruker", "UserCreatedWithName": "Bruker {0} er opprettet", "UserDeletedWithName": "Bruker {0} har blitt slettet", "UserDownloadingItemWithValues": "{0} laster ned {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} har koblet fra {1}", "UserOnlineFromDevice": "{0} er tilkoblet fra {1}", "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", - "UserPolicyUpdatedWithName": "Brukerretningslinjene har blitt oppdatert for {0}", "UserStartedPlayingItemWithValues": "{0} har startet avspilling {1} på {2}", "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling {1}", - "ValueHasBeenAddedToLibrary": "{0} har blitt lagt til i mediebiblioteket ditt", - "ValueSpecialEpisodeName": "Spesialepisode - {0}", "VersionNumber": "Versjon {0}", "TasksChannelsCategory": "Internettkanaler", "TasksApplicationCategory": "Applikasjon", diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json index 7c6b08fb36..ad6070c4c3 100644 --- a/Emby.Server.Implementations/Localization/Core/ne.json +++ b/Emby.Server.Implementations/Localization/Core/ne.json @@ -21,64 +21,39 @@ "Music": "संगीत", "Movies": "चलचित्रहरू", "MixedContent": "मिश्रित सामग्री", - "MessageServerConfigurationUpdated": "सर्भर कन्फिगरेसन अद्यावधिक गरिएको छ", - "MessageNamedServerConfigurationUpdatedWithValue": "सर्भर कन्फिगरेसन विभाग {0} अद्यावधिक गरिएको छ", - "MessageApplicationUpdatedTo": "जेलीफिन सर्भर {0} मा अद्यावधिक गरिएको छ", - "MessageApplicationUpdated": "जेलीफिन सर्भर अपडेट गरिएको छ", "Latest": "नविनतम", "LabelRunningTimeValue": "कुल समय: {0}", "LabelIpAddressValue": "आईपी ठेगाना: {0}", - "ItemRemovedWithName": "{0}लाई पुस्तकालयबाट हटाईयो", - "ItemAddedWithName": "{0} लाईब्रेरीमा थपियो", "Inherit": "उत्तराधिकार", "HomeVideos": "घरेलु भिडियोहरू", - "HeaderRecordingGroups": "रेकर्ड समूहहरू", "HeaderNextUp": "आगामी", "HeaderLiveTV": "प्रत्यक्ष टिभी", - "HeaderFavoriteSongs": "मनपर्ने गीतहरू", "HeaderFavoriteShows": "मनपर्ने कार्यक्रमहरू", "HeaderFavoriteEpisodes": "मनपर्ने एपिसोडहरू", - "HeaderFavoriteArtists": "मनपर्ने कलाकारहरू", - "HeaderFavoriteAlbums": "मनपर्ने एल्बमहरू", "HeaderContinueWatching": "हेर्न जारी राख्नुहोस्", - "HeaderAlbumArtists": "एल्बमका कलाकारहरू", "Genres": "विधाहरू", "Folders": "फोल्डरहरू", "Favorites": "मनपर्ने", - "FailedLoginAttemptWithUserName": "{0}को लग इन प्रयास असफल", - "DeviceOnlineWithName": "{0}को साथ जडित", - "DeviceOfflineWithName": "{0}बाट विच्छेदन भयो", + "FailedLoginAttemptWithUserName": "असफल लग इन प्रयास {0} देखि", "Collections": "संग्रह", "ChapterNameValue": "अध्याय {0}", - "Channels": "च्यानलहरू", "AppDeviceValues": "अनुप्रयोग: {0}, उपकरण: {1}", "AuthenticationSucceededWithUserName": "{0} सफलतापूर्वक प्रमाणीकरण गरियो", - "CameraImageUploadedFrom": "{0}बाट नयाँ क्यामेरा छवि अपलोड गरिएको छ", "Books": "पुस्तकहरु", "Artists": "कलाकारहरू", - "Application": "अनुप्रयोगहरू", - "Albums": "एल्बमहरू", "TasksLibraryCategory": "पुस्तकालय", "TasksApplicationCategory": "अनुप्रयोग", "TasksMaintenanceCategory": "मर्मत", - "UserPolicyUpdatedWithName": "प्रयोगकर्ता नीति को लागी अद्यावधिक गरिएको छ {0}", "UserPasswordChangedWithName": "पासवर्ड प्रयोगकर्ताका लागि परिवर्तन गरिएको छ {0}", "UserOnlineFromDevice": "{0} बाट अनलाइन छ {1}", "UserOfflineFromDevice": "{0} बाट विच्छेदन भएको छ {1}", "UserLockedOutWithName": "प्रयोगकर्ता {0} लक गरिएको छ", "UserDeletedWithName": "प्रयोगकर्ता {0} हटाइएको छ", "UserCreatedWithName": "प्रयोगकर्ता {0} सिर्जना गरिएको छ", - "User": "प्रयोगकर्ता", "PluginInstalledWithName": "{0} सभएको थियो", "StartupEmbyServerIsLoading": "Jellyfin सर्भर लोड हुँदैछ। कृपया छिट्टै फेरि प्रयास गर्नुहोस्।", - "Songs": "गीतहरू", "Shows": "शोहरू", - "ServerNameNeedsToBeRestarted": "{0} लाई पुन: सुरु गर्नु पर्छ", - "ScheduledTaskStartedWithName": "{0} सुरु भयो", "ScheduledTaskFailedWithName": "{0} असफल", - "ProviderValue": "प्रदायक: {0}", - "Plugin": "प्लगइनहरू", - "Playlists": "प्लेलिस्टहरू", "Photos": "तस्बिरहरु", "NotificationOptionVideoPlaybackStopped": "भिडियो प्लेब्याक रोकियो", "NotificationOptionVideoPlayback": "भिडियो प्लेब्याक सुरु भयो", @@ -98,15 +73,11 @@ "TaskCleanActivityLog": "गतिविधि लग सफा गर्नुहोस्", "TasksChannelsCategory": "इन्टरनेट च्यानलहरू", "VersionNumber": "संस्करण {0}", - "ValueSpecialEpisodeName": "विशेष - {0}", - "ValueHasBeenAddedToLibrary": "{0} तपाईंको मिडिया लाइब्रेरीमा थपिएको छ", "UserStoppedPlayingItemWithValues": "{2} मा {0} हेरिसकेको छ{1}", "UserStartedPlayingItemWithValues": "{0} हेर्दै {1} मा {2}", "UserDownloadingItemWithValues": "{0} डाउनलोड गर्दै छ {1}", "Undefined": "अपरिभाषित", "TvShows": "टेलिभिजन कार्यक्रमहरू", - "System": "प्रणाली", - "Sync": "समकालीन", "SubtitleDownloadFailureFromForItem": "उपशीर्षकहरू {0} बाट {1} को लागि डाउनलोड गर्न असफल", "PluginUpdatedWithName": "{0} अद्यावधिक गरिएको थियो", "PluginUninstalledWithName": "{0} को स्थापना रद्द गरिएको थियो", diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index bf1cbdacd1..9aea3adc22 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -1,39 +1,23 @@ { "AppDeviceValues": "App: {0}, Apparaat: {1}", - "Application": "Applicatie", "Artists": "Artiesten", "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", "Books": "Boeken", - "CameraImageUploadedFrom": "Nieuwe camera-afbeelding toegevoegd vanaf {0}", - "Channels": "Kanalen", "ChapterNameValue": "Hoofdstuk {0}", "Collections": "Collecties", - "DeviceOfflineWithName": "Verbinding met {0} is verbroken", - "DeviceOnlineWithName": "{0} is verbonden", "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "Favorites": "Favorieten", "Folders": "Mappen", - "HeaderAlbumArtists": "Albumartiesten", "HeaderContinueWatching": "Verderkijken", - "HeaderFavoriteAlbums": "Favoriete albums", - "HeaderFavoriteArtists": "Favoriete artiesten", "HeaderFavoriteEpisodes": "Favoriete afleveringen", "HeaderFavoriteShows": "Favoriete series", - "HeaderFavoriteSongs": "Favoriete nummers", "HeaderLiveTV": "Live-tv", "HeaderNextUp": "Volgende", - "HeaderRecordingGroups": "Opnamegroepen", "HomeVideos": "Homevideo's", "Inherit": "Overnemen", - "ItemAddedWithName": "{0} is toegevoegd aan de bibliotheek", - "ItemRemovedWithName": "{0} is verwijderd uit de bibliotheek", "LabelIpAddressValue": "IP-adres: {0}", "LabelRunningTimeValue": "Looptijd: {0}", "Latest": "Nieuwste", - "MessageApplicationUpdated": "Jellyfin Server is bijgewerkt", - "MessageApplicationUpdatedTo": "Jellyfin Server is bijgewerkt naar {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sectie {0} van de serverconfiguratie is bijgewerkt", - "MessageServerConfigurationUpdated": "Serverconfiguratie is bijgewerkt", "MixedContent": "Gemengde inhoud", "Movies": "Films", "Music": "Muziek", @@ -59,23 +43,14 @@ "NotificationOptionVideoPlayback": "Afspelen van video gestart", "NotificationOptionVideoPlaybackStopped": "Afspelen van video gestopt", "Photos": "Foto's", - "Playlists": "Afspeellijsten", - "Plugin": "Plug-in", "PluginInstalledWithName": "{0} is geïnstalleerd", "PluginUninstalledWithName": "{0} is verwijderd", "PluginUpdatedWithName": "{0} is bijgewerkt", - "ProviderValue": "Aanbieder: {0}", "ScheduledTaskFailedWithName": "{0} is mislukt", - "ScheduledTaskStartedWithName": "{0} is gestart", - "ServerNameNeedsToBeRestarted": "{0} moet herstart worden", "Shows": "Series", - "Songs": "Nummers", "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden. Probeer het later opnieuw.", "SubtitleDownloadFailureFromForItem": "Ondertiteling kon niet gedownload worden van {0} voor {1}", - "Sync": "Synchronisatie", - "System": "Systeem", "TvShows": "Tv-series", - "User": "Gebruiker", "UserCreatedWithName": "Gebruiker {0} is aangemaakt", "UserDeletedWithName": "Gebruiker {0} is verwijderd", "UserDownloadingItemWithValues": "{0} downloadt {1}", @@ -83,11 +58,8 @@ "UserOfflineFromDevice": "Verbinding van {0} via {1} is verbroken", "UserOnlineFromDevice": "{0} is verbonden via {1}", "UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd", - "UserPolicyUpdatedWithName": "Gebruikersbeleid gewijzigd voor {0}", "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}", "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}", - "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek", - "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Versie {0}", "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar ontbrekende ondertiteling gebaseerd op metadataconfiguratie.", "TaskDownloadMissingSubtitles": "Ontbrekende ondertiteling downloaden", @@ -134,6 +106,7 @@ "TaskExtractMediaSegments": "Scannen op mediasegmenten", "CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.", "CleanupUserDataTask": "Opruimtaak gebruikersdata", - "Albums": "Albums", - "Genres": "Genres" + "Genres": "Genres", + "Original": "Oorspronkelijk", + "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt" } diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json index feb5fe2154..8c5da8c1de 100644 --- a/Emby.Server.Implementations/Localization/Core/nn.json +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -1,41 +1,24 @@ { - "MessageServerConfigurationUpdated": "Tenarkonfigurasjonen har blitt oppdatert", - "MessageNamedServerConfigurationUpdatedWithValue": "Tenar konfigurasjon seksjon {0} har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin-tenaren har blitt oppdatert til {0}", - "MessageApplicationUpdated": "Jellyfin-tenaren har blitt oppdatert", "Latest": "Nyaste", "LabelRunningTimeValue": "Speletid: {0}", "LabelIpAddressValue": "IP-adresse: {0}", - "ItemRemovedWithName": "{0} vart fjerna frå biblioteket", - "ItemAddedWithName": "{0} vart lagt til i biblioteket", "Inherit": "Arve", "HomeVideos": "Heimevideoar", - "HeaderRecordingGroups": "Innspelingsgrupper", "HeaderNextUp": "Neste", "HeaderLiveTV": "Direkte TV", - "HeaderFavoriteSongs": "Favorittsongar", "HeaderFavoriteShows": "Favorittseriar", "HeaderFavoriteEpisodes": "Favorittepisodar", - "HeaderFavoriteArtists": "Favorittartistar", - "HeaderFavoriteAlbums": "Favorittalbum", "HeaderContinueWatching": "Fortsett å sjå", - "HeaderAlbumArtists": "Albumartist", "Genres": "Sjangrar", "Folders": "Mapper", "Favorites": "Favorittar", "FailedLoginAttemptWithUserName": "Mislukka påloggingsforsøk frå {0}", - "DeviceOnlineWithName": "{0} er tilkopla", - "DeviceOfflineWithName": "{0} har kopla frå", "Collections": "Samlingar", "ChapterNameValue": "Kapittel {0}", - "Channels": "Kanalar", - "CameraImageUploadedFrom": "Eit nytt kamerabilete har blitt lasta opp frå {0}", "Books": "Bøker", "AuthenticationSucceededWithUserName": "{0} har logga inn", "Artists": "Artistar", - "Application": "Program", "AppDeviceValues": "App: {0}, Eining: {1}", - "Albums": "Album", "NotificationOptionServerRestartRequired": "Tenaren krev omstart", "NotificationOptionPluginUpdateInstalled": "Programvaretilleggoppdatering vart installert", "NotificationOptionPluginUninstalled": "Programvaretillegg avinstallert", @@ -56,7 +39,6 @@ "Music": "Musikk", "Movies": "Filmar", "MixedContent": "Blanda innhald", - "Sync": "Synkroniser", "TaskDownloadMissingSubtitlesDescription": "Søk Internettet for manglande undertekstar basert på metadatainnstillingar.", "TaskDownloadMissingSubtitles": "Last ned manglande undertekstar", "TaskRefreshChannelsDescription": "Oppdater internettkanalinformasjon.", @@ -80,11 +62,8 @@ "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Vedlikehald", "VersionNumber": "Versjon {0}", - "ValueSpecialEpisodeName": "Spesialepisode - {0}", - "ValueHasBeenAddedToLibrary": "{0} har blitt lagt til i mediebiblioteket ditt", "UserStoppedPlayingItemWithValues": "{0} har fullført avspeling {1} på {2}", "UserStartedPlayingItemWithValues": "{0} spelar {1} på {2}", - "UserPolicyUpdatedWithName": "Brukarreglar har blitt oppdatert for {0}", "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", "UserOnlineFromDevice": "{0} er direktekopla frå {1}", "UserOfflineFromDevice": "{0} har kopla frå {1}", @@ -92,22 +71,14 @@ "UserDownloadingItemWithValues": "{0} lastar ned {1}", "UserDeletedWithName": "Brukar {0} er sletta", "UserCreatedWithName": "Brukar {0} er oppretta", - "User": "Brukar", "TvShows": "TV-seriar", - "System": "System", "SubtitleDownloadFailureFromForItem": "Feila å laste ned undertekstar frå {0} for {1}", "StartupEmbyServerIsLoading": "Jellyfin-tenaren laster. Prøv igjen seinare.", - "Songs": "Sangar", "Shows": "Seriar", - "ServerNameNeedsToBeRestarted": "{0} må omstartast", - "ScheduledTaskStartedWithName": "{0} starta", "ScheduledTaskFailedWithName": "{0} feila", - "ProviderValue": "Leverandør: {0}", "PluginUpdatedWithName": "{0} blei oppdatert", "PluginUninstalledWithName": "{0} blei avinstallert", "PluginInstalledWithName": "{0} blei installert", - "Plugin": "Programvaretillegg", - "Playlists": "Spelelister", "Photos": "Bilete", "NotificationOptionVideoPlaybackStopped": "Videoavspeling stoppa", "NotificationOptionVideoPlayback": "Videoavspeling starta", 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/or.json b/Emby.Server.Implementations/Localization/Core/or.json index 8251c12907..febc98d1a4 100644 --- a/Emby.Server.Implementations/Localization/Core/or.json +++ b/Emby.Server.Implementations/Localization/Core/or.json @@ -1,11 +1,8 @@ { "External": "ବହିଃସ୍ଥ", "Genres": "ଧରଣ", - "Albums": "ଆଲବମଗୁଡ଼ିକ", "Artists": "କଳାକାରଗୁଡ଼ିକ", - "Application": "ଆପ୍ଲିକେସନ", "Books": "ବହିଗୁଡ଼ିକ", - "Channels": "ଚ୍ୟାନେଲଗୁଡ଼ିକ", "ChapterNameValue": "ବିଭାଗ {0}", "Collections": "ସଂଗ୍ରହଗୁଡ଼ିକ", "Folders": "ଫୋଲ୍ଡରଗୁଡ଼ିକ" diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index b00291ccb4..e609ad52c8 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -24,11 +24,8 @@ "TasksLibraryCategory": "ਲਾਇਬ੍ਰੇਰੀ", "TasksMaintenanceCategory": "ਰੱਖ-ਰਖਾਅ", "VersionNumber": "ਵਰਜਨ {0}", - "ValueSpecialEpisodeName": "ਖਾਸ - {0}", - "ValueHasBeenAddedToLibrary": "{0} ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਹੈ", "UserStoppedPlayingItemWithValues": "{0} ਨੇ {2} 'ਤੇ {1} ਖੇਡਣਾ ਪੂਰਾ ਕਰ ਲਿਆ ਹੈ", "UserStartedPlayingItemWithValues": "{0} {2} 'ਤੇ {1} ਖੇਡ ਰਿਹਾ ਹੈ", - "UserPolicyUpdatedWithName": "ਵਰਤੋਂਕਾਰ ਨੀਤੀ ਨੂੰ {0} ਲਈ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", "UserPasswordChangedWithName": "{0} ਵਰਤੋਂਕਾਰ ਲਈ ਪਾਸਵਰਡ ਬਦਲਿਆ ਗਿਆ ਸੀ", "UserOnlineFromDevice": "{0} ਨੂੰ {1} ਤੋਂ ਆਨਲਾਈਨ ਹੈ", "UserOfflineFromDevice": "{0} ਤੋਂ ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ {1}", @@ -36,24 +33,15 @@ "UserDownloadingItemWithValues": "{0} {1} ਨੂੰ ਡਾਊਨਲੋਡ ਕਰ ਰਿਹਾ ਹੈ", "UserDeletedWithName": "ਵਰਤੋਂਕਾਰ {0} ਨੂੰ ਹਟਾਇਆ ਗਿਆ", "UserCreatedWithName": "ਵਰਤੋਂਕਾਰ {0} ਬਣਾਇਆ ਗਿਆ ਹੈ", - "User": "ਵਰਤੋਂਕਾਰ", "Undefined": "ਪਰਿਭਾਸ਼ਤ", "TvShows": "ਟੀਵੀ ਸ਼ੋਅ", - "System": "ਸਿਸਟਮ", - "Sync": "ਸਿੰਕ", "SubtitleDownloadFailureFromForItem": "ਉਪਸਿਰਲੇਖ {1} ਲਈ {0} ਤੋਂ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ", "StartupEmbyServerIsLoading": "Jellyfin ਸਰਵਰ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ। ਛੇਤੀ ਹੀ ਫ਼ੇਰ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", - "Songs": "ਗਾਣੇ", "Shows": "ਸ਼ੋਅ", - "ServerNameNeedsToBeRestarted": "{0} ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", - "ScheduledTaskStartedWithName": "{0} ਸ਼ੁਰੂ ਹੋਇਆ", "ScheduledTaskFailedWithName": "{0} ਅਸਫਲ", - "ProviderValue": "ਦੇਣ ਵਾਲੇ: {0}", "PluginUpdatedWithName": "{0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਸੀ", "PluginUninstalledWithName": "{0} ਅਣਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ ਸੀ", "PluginInstalledWithName": "{0} ਲਗਾਇਆ ਗਿਆ ਸੀ", - "Plugin": "ਪਲੱਗਇਨ", - "Playlists": "ਪਲੇਸੂਚੀਆਂ", "Photos": "ਫੋਟੋਆਂ", "NotificationOptionVideoPlaybackStopped": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਰੋਕਿਆ ਗਿਆ", "NotificationOptionVideoPlayback": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", @@ -79,45 +67,28 @@ "Music": "ਸੰਗੀਤ", "Movies": "ਫਿਲਮਾਂ", "MixedContent": "ਮਿਸ਼ਰਤ ਸਮੱਗਰੀ", - "MessageServerConfigurationUpdated": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਨੂੰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", - "MessageNamedServerConfigurationUpdatedWithValue": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਸੈਕਸ਼ਨ {0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", - "MessageApplicationUpdatedTo": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਨੂੰ ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ {0}", - "MessageApplicationUpdated": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", "Latest": "ਤਾਜ਼ਾ", "LabelRunningTimeValue": "ਚੱਲਦਾ ਸਮਾਂ: {0}", "LabelIpAddressValue": "IP ਪਤਾ: {0}", - "ItemRemovedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਸੀ", - "ItemAddedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਸੀ", "Inherit": "ਵਿਰਾਸਤ", "HomeVideos": "ਘਰੇਲੂ ਵੀਡੀਓ", - "HeaderRecordingGroups": "ਰਿਕਾਰਡਿੰਗ ਸਮੂਹ", "HeaderNextUp": "ਅੱਗੇ", "HeaderLiveTV": "ਲਾਈਵ ਟੀਵੀ", - "HeaderFavoriteSongs": "ਮਨਪਸੰਦ ਗਾਣੇ", "HeaderFavoriteShows": "ਮਨਪਸੰਦ ਸ਼ੋਅ", "HeaderFavoriteEpisodes": "ਮਨਪਸੰਦ ਐਪੀਸੋਡ", - "HeaderFavoriteArtists": "ਮਨਪਸੰਦ ਕਲਾਕਾਰ", - "HeaderFavoriteAlbums": "ਮਨਪਸੰਦ ਐਲਬਮ", "HeaderContinueWatching": "ਵੇਖਣਾ ਜਾਰੀ ਰੱਖੋ", - "HeaderAlbumArtists": "ਐਲਬਮ ਕਲਾਕਾਰ", "Genres": "ਸ਼ੈਲੀਆਂ", "Forced": "ਮਜਬੂਰ", "Folders": "ਫੋਲਡਰ", "Favorites": "ਮਨਪਸੰਦ", "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ", - "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", - "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", "Default": "ਡਿਫੌਲਟ", "Collections": "ਸੰਗ੍ਰਹਿਣ", "ChapterNameValue": "ਚੈਪਟਰ {0}", - "Channels": "ਚੈਨਲ", - "CameraImageUploadedFrom": "{0} ਤੋਂ ਇੱਕ ਨਵਾਂ ਕੈਮਰਾ ਚਿੱਤਰ ਅਪਲੋਡ ਕੀਤਾ ਗਿਆ ਹੈ", "Books": "ਕਿਤਾਬਾਂ", "AuthenticationSucceededWithUserName": "{0} ਸਫਲਤਾਪੂਰਕ ਪ੍ਰਮਾਣਿਤ", "Artists": "ਕਲਾਕਾਰ", - "Application": "ਐਪਲੀਕੇਸ਼ਨ", "AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}", - "Albums": "ਐਲਬਮਾਂ", "TaskOptimizeDatabase": "ਡਾਟਾਬੇਸ ਅਨੁਕੂਲ ਬਣਾਓ", "External": "ਬਾਹਰੀ", "HearingImpaired": "ਸੁਨਣ ਵਿਚ ਕਮਜ਼ੋਰ", diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index a741fc14c0..c4657bdd6e 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -1,41 +1,24 @@ { - "Albums": "Albumy", "AppDeviceValues": "Aplikacja: {0}, Urządzenie: {1}", - "Application": "Aplikacja", "Artists": "Wykonawcy", "AuthenticationSucceededWithUserName": "{0} został pomyślnie uwierzytelniony", "Books": "Książki", - "CameraImageUploadedFrom": "Nowy obraz został przekazany z {0}", - "Channels": "Kanały", "ChapterNameValue": "Rozdział {0}", "Collections": "Kolekcje", - "DeviceOfflineWithName": "{0} został rozłączony", - "DeviceOnlineWithName": "{0} połączył się", "FailedLoginAttemptWithUserName": "Nieudana próba logowania przez {0}", "Favorites": "Ulubione", "Folders": "Foldery", "Genres": "Gatunki", - "HeaderAlbumArtists": "Wykonawcy albumów", "HeaderContinueWatching": "Kontynuuj odtwarzanie", - "HeaderFavoriteAlbums": "Ulubione albumy", - "HeaderFavoriteArtists": "Ulubieni wykonawcy", "HeaderFavoriteEpisodes": "Ulubione odcinki", "HeaderFavoriteShows": "Ulubione seriale", - "HeaderFavoriteSongs": "Ulubione utwory", "HeaderLiveTV": "Telewizja", "HeaderNextUp": "Do obejrzenia", - "HeaderRecordingGroups": "Grupy nagrań", "HomeVideos": "Nagrania domowe", "Inherit": "Dziedzicz", - "ItemAddedWithName": "{0} zostało dodane do biblioteki", - "ItemRemovedWithName": "{0} zostało usunięte z biblioteki", "LabelIpAddressValue": "Adres IP: {0}", "LabelRunningTimeValue": "Czas trwania: {0}", "Latest": "Ostatnio dodane", - "MessageApplicationUpdated": "Serwer Jellyfin został zaktualizowany", - "MessageApplicationUpdatedTo": "Serwer Jellyfin został zaktualizowany do wersji {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sekcja {0} konfiguracji serwera została zaktualizowana", - "MessageServerConfigurationUpdated": "Konfiguracja serwera została zaktualizowana", "MixedContent": "Zawartość mieszana", "Movies": "Filmy", "Music": "Muzyka", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Rozpoczęto odtwarzanie wideo", "NotificationOptionVideoPlaybackStopped": "Zatrzymano odtwarzanie wideo", "Photos": "Zdjęcia", - "Playlists": "Listy odtwarzania", - "Plugin": "Wtyczka", "PluginInstalledWithName": "{0} zostało zainstalowane", "PluginUninstalledWithName": "{0} odinstalowane", "PluginUpdatedWithName": "{0} zaktualizowane", - "ProviderValue": "Dostawca: {0}", "ScheduledTaskFailedWithName": "Nieudane {0}", - "ScheduledTaskStartedWithName": "Rozpoczęto {0}", - "ServerNameNeedsToBeRestarted": "{0} wymaga ponownego uruchomienia", "Shows": "Seriale", - "Songs": "Utwory", "StartupEmbyServerIsLoading": "Trwa wczytywanie serwera Jellyfin. Spróbuj ponownie za chwilę.", "SubtitleDownloadFailureFromForItem": "Nieudane pobieranie napisów z {0} dla {1}", - "Sync": "Synchronizacja", - "System": "System", "TvShows": "Seriale", - "User": "Użytkownik", "UserCreatedWithName": "Użytkownik {0} został utworzony", "UserDeletedWithName": "Użytkownik {0} został usunięty", "UserDownloadingItemWithValues": "{0} pobiera {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} z {1} został rozłączony", "UserOnlineFromDevice": "{0} połączył się z {1}", "UserPasswordChangedWithName": "Hasło użytkownika {0} zostało zmienione", - "UserPolicyUpdatedWithName": "Zmieniono zasady użytkowania dla {0}", "UserStartedPlayingItemWithValues": "{0} odtwarza {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} zakończył odtwarzanie {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} został dodany do biblioteki mediów", - "ValueSpecialEpisodeName": "Specjalne - {0}", "VersionNumber": "Wersja {0}", "TaskDownloadMissingSubtitlesDescription": "Przeszukuje internet w poszukiwaniu brakujących napisów w oparciu o konfigurację metadanych.", "TaskDownloadMissingSubtitles": "Pobierz brakujące napisy", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.", "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.", "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", - "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika" + "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika", + "Original": "Oryginalny", + "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index fee7e65f1d..912f5c876a 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -2,48 +2,31 @@ "Books": "Scrolls", "AuthenticationSucceededWithUserName": "{0} passed yer trial", "Artists": "Artistas", - "Songs": "Shantees", - "Albums": "Tomes", "Photos": "Paintings", "NotificationOptionUserLockedOut": "Crewmate sent to the brig", "HeaderContinueWatching": "Continue Yer Journey", "Folders": "Chests", - "Application": "Captain", - "DeviceOnlineWithName": "{0} joined yer crew", - "DeviceOfflineWithName": "{0} abandoned ship", "AppDeviceValues": "Captain: {0}, Ship: {1}", - "CameraImageUploadedFrom": "Yer looking glass has glimpsed another painting from {0}", "Collections": "Barrels", - "ItemAddedWithName": "{0} is now with yer treasure", "Default": "Normal-like", "FailedLoginAttemptWithUserName": "Ye failed to enter from {0}", "Favorites": "Finest Loot", - "ItemRemovedWithName": "{0} was taken from yer treasure", "LabelIpAddressValue": "Ship's coordinates: {0}", "Genres": "types o' booty", "TaskDownloadMissingSubtitlesDescription": "Scours the seven seas o' the internet for subtitles that be missin' based on the captain's map o' metadata.", - "HeaderAlbumArtists": "Buccaneers o' the musical arts", - "HeaderFavoriteAlbums": "Beloved booty o' musical adventures", - "HeaderFavoriteArtists": "Treasured scallywags o' the creative seas", - "Channels": "Channels", "Forced": "Pressed", "External": "Outboard", "HeaderFavoriteEpisodes": "Treasured Tales", "HeaderFavoriteShows": "Treasured Tales", "ChapterNameValue": "Piece {0}", - "HeaderFavoriteSongs": "Treasured Chimes", "HeaderNextUp": "Incoming", "HeaderLiveTV": "Scrying Glass", "HearingImpaired": "Hard o' Hearing", "LabelRunningTimeValue": "Journey duration: {0}", - "MessageApplicationUpdated": "Yer Map of the Seas has been scribbled", "HomeVideos": "Yer Onboard Booty", "MixedContent": "Jumbled loot", "Music": "Tunes", "NameInstallFailed": "Ye couldn't bring {0} aboard yer ship", - "MessageApplicationUpdatedTo": "Yer Map of the Seas has been scribbled with {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Yer Map Drawer has been rescribbled to {0}", - "MessageServerConfigurationUpdated": "Yer Map drawer has been rescribbled", "Inherit": "Carry on what be passed along", "Latest": "Newfangled", "Movies": "Moving pictures", @@ -55,17 +38,13 @@ "UserOfflineFromDevice": "{0} severed ties with {1}", "UserDownloadingItemWithValues": "{0} be haulin’ in {1}", "UserStartedPlayingItemWithValues": "{0} be playin’ {1} aboard {2}", - "ValueHasBeenAddedToLibrary": "{0} be stashed in yer treasure trove", "TaskCleanCacheDescription": "Wipes away cache cargo no longer called fer.", "TaskCleanLogsDescription": "Clears the logbook o’ entries older than {0} days.", "TaskRefreshPeopleDescription": "Refreshes the charts fer actors an’ directors in yer Treasure Trove.", "UserLockedOutWithName": "Matey {0} be denied boarding", "TaskAudioNormalization": "Steadyin’ the shanties", "TaskAudioNormalizationDescription": "Scans files fer shanty steadiyin’ data.", - "HeaderRecordingGroups": "Loggin' Groups", "MusicVideos": "Shanty films", - "Playlists": "Lists o’ plunder", - "Plugin": "Extra sail", "NotificationOptionVideoPlaybackStopped": "Video playback dropped anchor", "NameSeasonNumber": "Saga {0}", "NameSeasonUnknown": "Saga be Lost", @@ -87,23 +66,15 @@ "TaskRefreshPeople": "Freshen the Mateys", "PluginUninstalledWithName": "{0} sent t’ Davy Jones", "PluginUpdatedWithName": "{0} patched ‘n ready", - "ProviderValue": "Supplier o’ goods: {0}", - "ScheduledTaskStartedWithName": "{0} set sail", - "ServerNameNeedsToBeRestarted": "{0} be cravin’ a restart", "Shows": "Sagas", "SubtitleDownloadFailureFromForItem": "Subtitles be sunk fetchin’ from {0} fer {1}", - "Sync": "Match the tides", - "System": "The ship’s works", "TvShows": "TV Sagas", "Undefined": "Uncharted", - "User": "Matey", "UserCreatedWithName": "Matey {0} joined the crew", "UserDeletedWithName": "Matey {0} cast overboard", "UserOnlineFromDevice": "{0} be aboard ship from {1}", "UserPasswordChangedWithName": "New passphrase set fer Matey {0}", - "UserPolicyUpdatedWithName": "Ship rules be changed fer {0}", "UserStoppedPlayingItemWithValues": "{0} be done playin’ {1} on {2", - "ValueSpecialEpisodeName": "Special Tale – {0}", "VersionNumber": "Edition {0}", "TasksMaintenanceCategory": "Hull patchin’", "TasksLibraryCategory": "Treasure Trove", diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 99f76c953f..1db500adf3 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -1,41 +1,24 @@ { - "Albums": "Álbuns", "AppDeviceValues": "App: {0}, Dispositivo: {1}", - "Application": "Aplicativo", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "Books": "Livros", - "CameraImageUploadedFrom": "Uma nova imagem da câmera foi enviada de {0}", - "Channels": "Canais", "ChapterNameValue": "Capítulo {0}", "Collections": "Coleções", - "DeviceOfflineWithName": "{0} se desconectou", - "DeviceOnlineWithName": "{0} se conectou", "FailedLoginAttemptWithUserName": "Falha na tentativa de login de {0}", "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Gêneros", - "HeaderAlbumArtists": "Artistas do Álbum", "HeaderContinueWatching": "Continuar assistindo", - "HeaderFavoriteAlbums": "Álbuns Favoritos", - "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episódios favoritos", "HeaderFavoriteShows": "Séries favoritas", - "HeaderFavoriteSongs": "Músicas favoritas", "HeaderLiveTV": "TV ao Vivo", "HeaderNextUp": "A Seguir", - "HeaderRecordingGroups": "Grupos de Gravação", "HomeVideos": "Vídeos caseiros", "Inherit": "Herdar", - "ItemAddedWithName": "{0} foi adicionado à biblioteca", - "ItemRemovedWithName": "{0} foi removido da biblioteca", "LabelIpAddressValue": "Endereço IP: {0}", "LabelRunningTimeValue": "Tempo de execução: {0}", "Latest": "Recentes", - "MessageApplicationUpdated": "Servidor Jellyfin atualizado", - "MessageApplicationUpdatedTo": "Servidor Jellyfin atualizado para {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "A seção {0} da configuração do servidor foi atualizada", - "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", "MixedContent": "Conteúdo misto", "Movies": "Filmes", "Music": "Música", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", "Photos": "Fotos", - "Playlists": "Listas de Reprodução", - "Plugin": "Plugin", "PluginInstalledWithName": "{0} foi instalado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginUpdatedWithName": "{0} foi atualizado", - "ProviderValue": "Provedor: {0}", "ScheduledTaskFailedWithName": "{0} falhou", - "ScheduledTaskStartedWithName": "{0} iniciada", - "ServerNameNeedsToBeRestarted": "O servidor {0} precisa ser reiniciado", "Shows": "Séries", - "Songs": "Músicas", "StartupEmbyServerIsLoading": "O Servidor Jellyfin está carregando. Por favor, tente novamente mais tarde.", "SubtitleDownloadFailureFromForItem": "Houve um problema ao baixar as legendas de {0} para {1}", - "Sync": "Sincronizar", - "System": "Sistema", "TvShows": "Séries", - "User": "Usuário", "UserCreatedWithName": "O usuário {0} foi criado", "UserDeletedWithName": "O usuário {0} foi excluído", "UserDownloadingItemWithValues": "{0} está baixando {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} se desconectou de {1}", "UserOnlineFromDevice": "{0} está online em {1}", "UserPasswordChangedWithName": "A senha foi alterada para o usuário {0}", - "UserPolicyUpdatedWithName": "A política de usuário foi atualizada para {0}", "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", "UserStoppedPlayingItemWithValues": "{0} parou de reproduzir {1} em {2}", - "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua biblioteca de mídia", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versão {0}", "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas faltando baseado na configuração de metadados.", "TaskDownloadMissingSubtitles": "Baixar legendas que estão faltando", @@ -135,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 1d31efcdc9..ce7f6d120e 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -1,41 +1,24 @@ { - "Albums": "Álbuns", "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}", - "Application": "Aplicação", "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "Books": "Livros", - "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}", - "Channels": "Canais", "ChapterNameValue": "Capítulo {0}", "Collections": "Coleções", - "DeviceOfflineWithName": "{0} desligou-se", - "DeviceOnlineWithName": "{0} ligou-se", "FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}", "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas do álbum", "HeaderContinueWatching": "Continuar a ver", - "HeaderFavoriteAlbums": "Álbuns Favoritos", - "HeaderFavoriteArtists": "Artistas Favoritos", "HeaderFavoriteEpisodes": "Episódios Favoritos", "HeaderFavoriteShows": "Séries Favoritas", - "HeaderFavoriteSongs": "Músicas Favoritas", "HeaderLiveTV": "TV em Direto", "HeaderNextUp": "A Seguir", - "HeaderRecordingGroups": "Grupos de Gravação", "HomeVideos": "Vídeos Caseiros", "Inherit": "Herdar", - "ItemAddedWithName": "{0} foi adicionado à mediateca", - "ItemRemovedWithName": "{0} foi removido da mediateca", "LabelIpAddressValue": "Endereço IP: {0}", "LabelRunningTimeValue": "Duração: {0}", "Latest": "Mais Recente", - "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", - "MessageApplicationUpdatedTo": "O servidor Jellyfin foi atualizado para a versão {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Configurações de servidor na secção {0} foram atualizadas", - "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", "MixedContent": "Conteúdo Misto", "Movies": "Filmes", "Music": "Música", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada", "NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada", "Photos": "Fotografias", - "Playlists": "Playlists", - "Plugin": "Extensão", "PluginInstalledWithName": "{0} foi instalado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginUpdatedWithName": "{0} foi atualizado", - "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} falhou", - "ScheduledTaskStartedWithName": "{0} iniciou", - "ServerNameNeedsToBeRestarted": "{0} necessita de ser reiniciado", "Shows": "Séries", - "Songs": "Músicas", "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.", "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}", - "Sync": "Sincronização", - "System": "Sistema", "TvShows": "Séries", - "User": "Utilizador", "UserCreatedWithName": "Utilizador {0} criado", "UserDeletedWithName": "Utilizador {0} apagado", "UserDownloadingItemWithValues": "{0} está a transferir {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} desligou-se a partir de {1}", "UserOnlineFromDevice": "{0} ligou-se a partir de {1}", "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", - "UserPolicyUpdatedWithName": "Política de utilizador alterada para {0}", "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", - "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua mediateca", - "ValueSpecialEpisodeName": "Especial - {0}", "VersionNumber": "Versão {0}", "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.", "TaskDownloadMissingSubtitles": "Transferir legendas em falta", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", - "CleanupUserDataTask": "Limpeza de dados de utilizador" + "CleanupUserDataTask": "Limpeza de dados de utilizador", + "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 82da1f0aff..ce338acf34 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -3,44 +3,30 @@ "Collections": "Coleções", "Books": "Livros", "Artists": "Artistas", - "Albums": "Álbuns", "HeaderNextUp": "A Seguir", - "HeaderFavoriteSongs": "Músicas Favoritas", - "HeaderFavoriteArtists": "Artistas Favoritos", - "HeaderFavoriteAlbums": "Álbuns Favoritos", "HeaderFavoriteEpisodes": "Episódios Favoritos", "HeaderFavoriteShows": "Séries Favoritas", "HeaderContinueWatching": "Continuar a ver", - "HeaderAlbumArtists": "Artistas do Álbum", "Genres": "Géneros", "Folders": "Pastas", "Favorites": "Favoritos", - "Channels": "Canais", "UserDownloadingItemWithValues": "{0} está sendo baixado {1}", "VersionNumber": "Versão {0}", - "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua mediateca", "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", - "UserPolicyUpdatedWithName": "A política do usuário {0} foi alterada", "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada", "UserOnlineFromDevice": "{0} está online a partir de {1}", "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}", "UserLockedOutWithName": "O usuário {0} foi bloqueado", "UserDeletedWithName": "O usuário {0} foi removido", "UserCreatedWithName": "O usuário {0} foi criado", - "User": "Usuário", "TvShows": "Séries", - "System": "Sistema", "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}", "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.", - "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciado", - "ScheduledTaskStartedWithName": "{0} iniciou", "ScheduledTaskFailedWithName": "{0} falhou", - "ProviderValue": "Fornecedor: {0}", "PluginUpdatedWithName": "{0} foi atualizado", "PluginUninstalledWithName": "{0} foi desinstalado", "PluginInstalledWithName": "{0} foi instalado", - "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", "NotificationOptionUserLockedOut": "Usuário bloqueado", @@ -64,32 +50,17 @@ "MusicVideos": "Videoclipes", "Music": "Música", "MixedContent": "Conteúdo diverso", - "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", - "MessageNamedServerConfigurationUpdatedWithValue": "As configurações do servidor na seção {0} foram atualizadas", - "MessageApplicationUpdatedTo": "O servidor Jellyfin foi atualizado para a versão {0}", - "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", "Latest": "Mais Recente", "LabelRunningTimeValue": "Duração: {0}", "LabelIpAddressValue": "Endereço de IP: {0}", - "ItemRemovedWithName": "{0} foi removido da mediateca", - "ItemAddedWithName": "{0} foi adicionado à mediateca", "Inherit": "Herdar", "HomeVideos": "Vídeos Caseiros", - "HeaderRecordingGroups": "Grupos de Gravação", - "ValueSpecialEpisodeName": "Especial - {0}", - "Sync": "Sincronização", - "Songs": "Músicas", "Shows": "Séries", - "Playlists": "Playlists", "Photos": "Fotografias", "Movies": "Filmes", "FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}", - "DeviceOnlineWithName": "{0} está ligado", - "DeviceOfflineWithName": "{0} desligou-se", "ChapterNameValue": "Capítulo {0}", - "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", - "Application": "Aplicação", "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}", "TaskCleanCache": "Limpar Diretório de Cache", "TasksApplicationCategory": "Aplicação", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.", "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay", "CleanupUserDataTask": "Task de limpeza de dados do usuário", - "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias." + "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", + "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index 30214218f8..ea83b88951 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -1,11 +1,8 @@ { "HeaderNextUp": "Urmează", "VersionNumber": "Versiunea {0}", - "ValueSpecialEpisodeName": "Special - {0}", - "ValueHasBeenAddedToLibrary": "{0} a fost adăugat la biblioteca multimedia", "UserStoppedPlayingItemWithValues": "{0} a terminat rularea {1} pe {2}", "UserStartedPlayingItemWithValues": "{0} ruleaza {1} pe {2}", - "UserPolicyUpdatedWithName": "Politica utilizatorului {0} a fost actualizată", "UserPasswordChangedWithName": "Parola utilizatorului {0} a fost schimbată", "UserOnlineFromDevice": "{0} este conectat de la {1}", "UserOfflineFromDevice": "{0} s-a deconectat de la {1}", @@ -13,23 +10,14 @@ "UserDownloadingItemWithValues": "{0} descarcă {1}", "UserDeletedWithName": "Utilizatorul {0} a fost șters", "UserCreatedWithName": "Utilizatorul {0} a fost creat", - "User": "Utilizator", "TvShows": "Seriale TV", - "System": "Sistem", - "Sync": "Sincronizare", "SubtitleDownloadFailureFromForItem": "Subtitrările nu au putut fi descărcate de la {0} pentru {1}", "StartupEmbyServerIsLoading": "Se încarcă serverul Jellyfin. Încercați din nou în scurt timp.", - "Songs": "Melodii", "Shows": "Seriale", - "ServerNameNeedsToBeRestarted": "{0} trebuie să fie repornit", - "ScheduledTaskStartedWithName": "{0} pornit/ă", "ScheduledTaskFailedWithName": "{0} eșuat/ă", - "ProviderValue": "Furnizor: {0}", "PluginUpdatedWithName": "{0} a fost actualizat/ă", "PluginUninstalledWithName": "{0} a fost dezinstalat", "PluginInstalledWithName": "{0} a fost instalat", - "Plugin": "Extensie", - "Playlists": "Liste de redare", "Photos": "Fotografii", "NotificationOptionVideoPlaybackStopped": "Redarea video oprită", "NotificationOptionVideoPlayback": "Redare video începută", @@ -55,42 +43,25 @@ "Music": "Muzică", "Movies": "Filme", "MixedContent": "Conținut amestecat", - "MessageServerConfigurationUpdated": "Configurarea serverului a fost actualizată", - "MessageNamedServerConfigurationUpdatedWithValue": "Secțiunea de configurare a serverului {0} a fost acualizata", - "MessageApplicationUpdatedTo": "Jellyfin Server a fost actualizat la {0}", - "MessageApplicationUpdated": "Jellyfin Server a fost actualizat", "Latest": "Cele mai recente", "LabelRunningTimeValue": "Durată: {0}", "LabelIpAddressValue": "Adresa IP: {0}", - "ItemRemovedWithName": "{0} a fost eliminat din bibliotecă", - "ItemAddedWithName": "{0} a fost adăugat în bibliotecă", "Inherit": "Moștenit", "HomeVideos": "Filme personale", - "HeaderRecordingGroups": "Grupuri de înregistrare", "HeaderLiveTV": "TV în Direct", - "HeaderFavoriteSongs": "Melodii Favorite", "HeaderFavoriteShows": "Seriale TV Favorite", "HeaderFavoriteEpisodes": "Episoade Favorite", - "HeaderFavoriteArtists": "Artiști Favoriți", - "HeaderFavoriteAlbums": "Albume Favorite", "HeaderContinueWatching": "Vizionează în continuare", - "HeaderAlbumArtists": "Artiști album", "Genres": "Genuri", "Folders": "Dosare", "Favorites": "Preferate", "FailedLoginAttemptWithUserName": "Încercare de conectare eșuată pentru {0}", - "DeviceOnlineWithName": "{0} este conectat", - "DeviceOfflineWithName": "{0} s-a deconectat", "Collections": "Colecții", "ChapterNameValue": "Capitolul {0}", - "Channels": "Canale", - "CameraImageUploadedFrom": "O nouă fotografie a fost încărcată din {0}", "Books": "Cărți", "AuthenticationSucceededWithUserName": "{0} autentificare reușită", "Artists": "Artiști", - "Application": "Aplicație", "AppDeviceValues": "Aplicație: {0}, Dispozitiv: {1}", - "Albums": "Albume", "TaskDownloadMissingSubtitlesDescription": "Caută pe internet subtitrările lipsă pe baza configurației metadatelor.", "TaskDownloadMissingSubtitles": "Descarcă subtitrările lipsă", "TaskRefreshChannelsDescription": "Actualizează informațiile despre canalul de internet.", @@ -135,5 +106,7 @@ "TaskDownloadMissingLyrics": "Descarcă versurile lipsă", "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii", "CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului", - "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile." + "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.", + "LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 38920b6ede..40d5e3985d 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -1,41 +1,24 @@ { - "Albums": "Альбомы", "AppDeviceValues": "Приложение: {0}, Устройство: {1}", - "Application": "Приложение", "Artists": "Исполнители", "AuthenticationSucceededWithUserName": "{0} - авторизация успешна", "Books": "Книги", - "CameraImageUploadedFrom": "Новое фото загружено с камеры {0}", - "Channels": "Каналы", "ChapterNameValue": "Сцена {0}", "Collections": "Коллекции", - "DeviceOfflineWithName": "{0} - отключено", - "DeviceOnlineWithName": "{0} - подключено", "FailedLoginAttemptWithUserName": "Неудачная попытка входа с {0}", "Favorites": "Избранное", "Folders": "Папки", "Genres": "Жанры", - "HeaderAlbumArtists": "Исполнители альбома", "HeaderContinueWatching": "Продолжить просмотр", - "HeaderFavoriteAlbums": "Избранные альбомы", - "HeaderFavoriteArtists": "Избранные исполнители", "HeaderFavoriteEpisodes": "Избранные эпизоды", "HeaderFavoriteShows": "Избранные сериалы", - "HeaderFavoriteSongs": "Избранные композиции", "HeaderLiveTV": "Эфир", "HeaderNextUp": "Следующий", - "HeaderRecordingGroups": "Группы записей", "HomeVideos": "Домашние видео", "Inherit": "Наследуемое", - "ItemAddedWithName": "{0} - добавлено в медиатеку", - "ItemRemovedWithName": "{0} - изъято из медиатеки", "LabelIpAddressValue": "IP-адрес: {0}", "LabelRunningTimeValue": "Длительность: {0}", "Latest": "Последние", - "MessageApplicationUpdated": "Jellyfin Server был обновлён", - "MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена", - "MessageServerConfigurationUpdated": "Конфигурация сервера была обновлена", "MixedContent": "Смешанное содержание", "Movies": "Фильмы", "Music": "Музыка", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Воспроизведение видео запущено", "NotificationOptionVideoPlaybackStopped": "Воспроизведение видео остановлено", "Photos": "Фото", - "Playlists": "Плей-листы", - "Plugin": "Плагин", "PluginInstalledWithName": "{0} - было установлено", "PluginUninstalledWithName": "{0} - было удалено", "PluginUpdatedWithName": "{0} - было обновлено", - "ProviderValue": "Поставщик: {0}", "ScheduledTaskFailedWithName": "{0} - неудачна", - "ScheduledTaskStartedWithName": "{0} - запущена", - "ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}", "Shows": "Сериалы", - "Songs": "Композиции", "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", - "Sync": "Синхронизация", - "System": "Система", "TvShows": "Телесериалы", - "User": "Пользователь", "UserCreatedWithName": "Пользователь {0} был создан", "UserDeletedWithName": "Пользователь {0} был удалён", "UserDownloadingItemWithValues": "{0} загружает {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} отключился с {1}", "UserOnlineFromDevice": "{0} подключился с {1}", "UserPasswordChangedWithName": "Пароль пользователя {0} был изменён", - "UserPolicyUpdatedWithName": "Политики пользователя {0} были обновлены", "UserStartedPlayingItemWithValues": "{0} - воспроизведение «{1}» на {2}", "UserStoppedPlayingItemWithValues": "{0} - воспроизведение остановлено «{1}» на {2}", - "ValueHasBeenAddedToLibrary": "{0} добавлено в медиатеку", - "ValueSpecialEpisodeName": "Спецэпизод - {0}", "VersionNumber": "Версия {0}", "TaskDownloadMissingSubtitles": "Загрузка отсутствующих субтитров", "TaskRefreshChannels": "Обновление каналов", @@ -135,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Извлекает или получает медиасегменты из плагинов MediaSegment.", "TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.", "CleanupUserDataTask": "Задача очистки пользовательских данных", - "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней." + "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.", + "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 184e9b0a5c..7ae8857e5d 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -1,41 +1,24 @@ { - "Albums": "Albumy", "AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}", - "Application": "Aplikácia", "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspešne overený", "Books": "Knihy", - "CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia", - "Channels": "Kanály", "ChapterNameValue": "Kapitola {0}", "Collections": "Kolekcie", - "DeviceOfflineWithName": "{0} sa odpojil", - "DeviceOnlineWithName": "{0} je pripojený", "FailedLoginAttemptWithUserName": "Neúspešný pokus o prihlásenie z {0}", "Favorites": "Obľúbené", "Folders": "Priečinky", "Genres": "Žánre", - "HeaderAlbumArtists": "Interpreti albumu", "HeaderContinueWatching": "Pokračovať v pozeraní", - "HeaderFavoriteAlbums": "Obľúbené albumy", - "HeaderFavoriteArtists": "Obľúbení interpreti", "HeaderFavoriteEpisodes": "Obľúbené epizódy", "HeaderFavoriteShows": "Obľúbené seriály", - "HeaderFavoriteSongs": "Obľúbené skladby", "HeaderLiveTV": "Živá TV", "HeaderNextUp": "Nasleduje", - "HeaderRecordingGroups": "Skupiny nahrávok", "HomeVideos": "Domáce videá", "Inherit": "Zdediť", - "ItemAddedWithName": "{0} bol pridaný do knižnice", - "ItemRemovedWithName": "{0} bol odstránený z knižnice", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Dĺžka: {0}", "Latest": "Najnovšie", - "MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný", - "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizovaný na verziu {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná", - "MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná", "MixedContent": "Zmiešaný obsah", "Movies": "Filmy", "Music": "Hudba", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Spustené prehrávanie videa", "NotificationOptionVideoPlaybackStopped": "Zastavené prehrávanie videa", "Photos": "Fotky", - "Playlists": "Playlisty", - "Plugin": "Zásuvný modul", "PluginInstalledWithName": "{0} bol nainštalovaný", "PluginUninstalledWithName": "{0} bol odinštalovaný", "PluginUpdatedWithName": "{0} bol aktualizovaný", - "ProviderValue": "Poskytovateľ: {0}", "ScheduledTaskFailedWithName": "{0} zlyhalo", - "ScheduledTaskStartedWithName": "{0} zahájených", - "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart", "Shows": "Seriály", - "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.", "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo", - "Sync": "Synchronizácia", - "System": "Systém", "TvShows": "TV seriály", - "User": "Používateľ", "UserCreatedWithName": "Používateľ {0} bol vytvorený", "UserDeletedWithName": "Používateľ {0} bol vymazaný", "UserDownloadingItemWithValues": "{0} sťahuje {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} sa odpojil od {1}", "UserOnlineFromDevice": "{0} je online z {1}", "UserPasswordChangedWithName": "Heslo používateľa {0} bolo zmenené", - "UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované", "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} bol pridaný do vašej knižnice médií", - "ValueSpecialEpisodeName": "Špeciál - {0}", "VersionNumber": "Verzia {0}", "TaskDownloadMissingSubtitlesDescription": "Vyhľadá na internete chýbajúce titulky podľa toho, ako sú nakonfigurované metadáta.", "TaskDownloadMissingSubtitles": "Stiahnuť chýbajúce titulky", @@ -135,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 35c5b4a914..a1b5b714af 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -1,41 +1,24 @@ { - "Albums": "Albumi", "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}", - "Application": "Aplikacija", "Artists": "Izvajalci", "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil/a", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}", - "Channels": "Kanali", "ChapterNameValue": "Poglavje {0}", "Collections": "Zbirke", - "DeviceOfflineWithName": "{0} je prekinil povezavo", - "DeviceOnlineWithName": "{0} je povezan", "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave z {0}", "Favorites": "Priljubljeno", "Folders": "Mape", "Genres": "Zvrsti", - "HeaderAlbumArtists": "Izvajalci albuma", "HeaderContinueWatching": "Nadaljuj ogled", - "HeaderFavoriteAlbums": "Priljubljeni albumi", - "HeaderFavoriteArtists": "Priljubljeni izvajalci", "HeaderFavoriteEpisodes": "Priljubljene epizode", "HeaderFavoriteShows": "Priljubljene serije", - "HeaderFavoriteSongs": "Priljubljene pesmi", "HeaderLiveTV": "TV v živo", "HeaderNextUp": "Sledi", - "HeaderRecordingGroups": "Zbirke posnetkov", "HomeVideos": "Domači posnetki", "Inherit": "Podeduj", - "ItemAddedWithName": "{0} je dodan v knjižnico", - "ItemRemovedWithName": "{0} je bil odstranjen iz knjižnice", "LabelIpAddressValue": "IP naslov: {0}", "LabelRunningTimeValue": "Čas trajanja: {0}", "Latest": "Najnovejše", - "MessageApplicationUpdated": "Jellyfin strežnik je bil posodobljen", - "MessageApplicationUpdatedTo": "Jellyfin strežnik je bil posodobljen na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitev {0} je bil posodobljen", - "MessageServerConfigurationUpdated": "Nastavitve strežnika so bile posodobljene", "MixedContent": "Mešane vsebine", "Movies": "Filmi", "Music": "Glasba", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Predvajanje videa se je začelo", "NotificationOptionVideoPlaybackStopped": "Predvajanje videa se je ustavilo", "Photos": "Fotografije", - "Playlists": "Seznami predvajanja", - "Plugin": "Dodatek", "PluginInstalledWithName": "{0} je bil nameščen", "PluginUninstalledWithName": "{0} je bil odstranjen", "PluginUpdatedWithName": "{0} je bil posodobljen", - "ProviderValue": "Ponudnik: {0}", "ScheduledTaskFailedWithName": "{0} ni uspelo", - "ScheduledTaskStartedWithName": "{0} začeto", - "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan", "Shows": "Serije", - "Songs": "Pesmi", "StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.", "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}", - "Sync": "Sinhroniziraj", - "System": "Sistem", "TvShows": "TV serije", - "User": "Uporabnik", "UserCreatedWithName": "Uporabnik {0} je bil ustvarjen", "UserDeletedWithName": "Uporabnik {0} je bil izbrisan", "UserDownloadingItemWithValues": "{0} prenaša {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} je prekinil povezavo z {1}", "UserOnlineFromDevice": "{0} je aktiven na {1}", "UserPasswordChangedWithName": "Geslo za uporabnika {0} je bilo spremenjeno", - "UserPolicyUpdatedWithName": "Pravilnik uporabe je bil posodobljen za uporabnika {0}", "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici", - "ValueSpecialEpisodeName": "Posebna epizoda - {0}", "VersionNumber": "Različica {0}", "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise", "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.", @@ -135,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/sn.json b/Emby.Server.Implementations/Localization/Core/sn.json index 74720e7646..45a459cbe1 100644 --- a/Emby.Server.Implementations/Localization/Core/sn.json +++ b/Emby.Server.Implementations/Localization/Core/sn.json @@ -1,28 +1,18 @@ { - "HeaderAlbumArtists": "Vaimbi vemadambarefu", "HeaderContinueWatching": "Simudzira kuona", - "HeaderFavoriteSongs": "Nziyo dzaunofarira", - "Albums": "Dambarefu", "AppDeviceValues": "Apu: {0}, Dhivhaisi: {1}", - "Application": "Purogiramu", "Artists": "Vaimbi", "AuthenticationSucceededWithUserName": "apinda", "Books": "Mabhuku", - "CameraImageUploadedFrom": "Mufananidzo mutsva vabva pakamera {0}", - "Channels": "Machanewo", "ChapterNameValue": "Chikamu {0}", "Collections": "Akafanana", "Default": "Zvakasarudzwa Kare", - "DeviceOfflineWithName": "{0} haasisipo", - "DeviceOnlineWithName": "{0} aripo", "External": "Zvekunze", "FailedLoginAttemptWithUserName": "Vatadza kuloga chimboedza kushandisa {0}", "Favorites": "Zvaunofarira", "Folders": "Mafoodha", "Forced": "Zvekumanikidzira", "Genres": "Mhando", - "HeaderFavoriteAlbums": "Madambarefu aunofarira", - "HeaderFavoriteArtists": "Vaimbi vaunofarira", "HeaderFavoriteEpisodes": "Maepisodhi aunofarira", "HeaderFavoriteShows": "Masirisi aunofarira" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index 5a284e20b9..e13f7b09e1 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -1,5 +1,4 @@ { - "MessageApplicationUpdatedTo": "Serveri Jellyfin u përditesua në versionin {0}", "Inherit": "Trashgimi", "TaskDownloadMissingSubtitlesDescription": "Kërkon në internet për titra që mungojnë bazuar tek konfigurimi i metadata-ve.", "TaskDownloadMissingSubtitles": "Shkarko titra që mungojnë", @@ -24,11 +23,8 @@ "TasksLibraryCategory": "Libraria", "TasksMaintenanceCategory": "Mirëmbajtje", "VersionNumber": "Versioni {0}", - "ValueSpecialEpisodeName": "Speciale - {0}", - "ValueHasBeenAddedToLibrary": "{0} u shtua tek libraria juaj", "UserStoppedPlayingItemWithValues": "{0} mbaroi së shikuari {1} tek {2}", "UserStartedPlayingItemWithValues": "{0} po shikon {1} tek {2}", - "UserPolicyUpdatedWithName": "Politika e përdoruesit u përditësua për {0}", "UserPasswordChangedWithName": "Fjalëkalimi u ndryshua për përdoruesin {0}", "UserOnlineFromDevice": "{0} është në linjë nga {1}", "UserOfflineFromDevice": "{0} u shkëput nga {1}", @@ -36,23 +32,14 @@ "UserDownloadingItemWithValues": "{0} po shkarkon {1}", "UserDeletedWithName": "Përdoruesi {0} u fshi", "UserCreatedWithName": "Përdoruesi {0} u krijua", - "User": "Përdoruesi", "TvShows": "Seriale TV", - "System": "Sistemi", - "Sync": "Sinkronizo", "SubtitleDownloadFailureFromForItem": "Titrat deshtuan të shkarkohen nga {0} për {1}", "StartupEmbyServerIsLoading": "Serveri Jellyfin po ngarkohet. Ju lutemi provoni përseri pas pak.", - "Songs": "Këngët", "Shows": "Serialet", - "ServerNameNeedsToBeRestarted": "{0} duhet të ristartoj", - "ScheduledTaskStartedWithName": "{0} filloi", "ScheduledTaskFailedWithName": "{0} dështoi", - "ProviderValue": "Ofruesi: {0}", "PluginUpdatedWithName": "{0} u përditësua", "PluginUninstalledWithName": "{0} u çinstalua", "PluginInstalledWithName": "{0} u instalua", - "Plugin": "Plugin", - "Playlists": "Listat për luajtje", "Photos": "Fotografitë", "NotificationOptionVideoPlaybackStopped": "Luajtja e videos ndaloi", "NotificationOptionVideoPlayback": "Luajtja e videos filloi", @@ -78,41 +65,25 @@ "Music": "Muzikë", "Movies": "Filmat", "MixedContent": "Përmbajtje e përzier", - "MessageServerConfigurationUpdated": "Konfigurimet e serverit u përditësuan", - "MessageNamedServerConfigurationUpdatedWithValue": "Seksioni i konfigurimit të serverit {0} u përditësua", - "MessageApplicationUpdated": "Serveri Jellyfin u përditësua", "Latest": "Të fundit", "LabelRunningTimeValue": "Kohëzgjatja: {0}", "LabelIpAddressValue": "Adresa IP: {0}", - "ItemRemovedWithName": "{0} u fshi nga libraria", - "ItemAddedWithName": "{0} u shtua tek libraria", "HomeVideos": "Video personale", - "HeaderRecordingGroups": "Grupet e regjistrimit", "HeaderNextUp": "Në vazhdim", "HeaderLiveTV": "TV Live", - "HeaderFavoriteSongs": "Kënget e preferuara", "HeaderFavoriteShows": "Serialet e preferuar", "HeaderFavoriteEpisodes": "Episodet e preferuar", - "HeaderFavoriteArtists": "Artistët e preferuar", - "HeaderFavoriteAlbums": "Albumet e preferuar", "HeaderContinueWatching": "Vazhdo të shikosh", - "HeaderAlbumArtists": "Artistët e albumeve", "Genres": "Zhanret", "Folders": "Skedarët", "Favorites": "Të preferuarat", "FailedLoginAttemptWithUserName": "Përpjekja për hyrje dështoi nga {0}", - "DeviceOnlineWithName": "{0} u lidh", - "DeviceOfflineWithName": "{0} u shkëput", "Collections": "Koleksionet", "ChapterNameValue": "Kapituj", - "Channels": "Kanalet", - "CameraImageUploadedFrom": "Një foto e re nga kamera u ngarkua nga {0}", "Books": "Librat", "AuthenticationSucceededWithUserName": "{0} u identifikua me sukses", "Artists": "Artistët", - "Application": "Aplikacioni", "AppDeviceValues": "Aplikacioni: {0}, Pajisja: {1}", - "Albums": "Albumet", "TaskCleanActivityLogDescription": "Pastro të dhënat mbi aktivitetin më të vjetra sesa koha e përcaktuar.", "TaskCleanActivityLog": "Pastro të dhënat mbi aktivitetin", "Undefined": "I papërcaktuar", @@ -135,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 52f4124657..92f309c80c 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -1,9 +1,6 @@ { - "UserPolicyUpdatedWithName": "Корисничке смернице ажуриране за {0}", "NotificationOptionUserLockedOut": "Корисник закључан", "VersionNumber": "Верзија {0}", - "ValueSpecialEpisodeName": "Специјал - {0}", - "ValueHasBeenAddedToLibrary": "{0} је додато у вашу медијску библиотеку", "UserStoppedPlayingItemWithValues": "{0} завршио пуштање {1} на {2}", "UserStartedPlayingItemWithValues": "{0} пушта {1} на {2}", "UserPasswordChangedWithName": "Лозинка је промењена за корисника {0}", @@ -13,23 +10,14 @@ "UserDownloadingItemWithValues": "{0} преузима {1}", "UserDeletedWithName": "Корисник {0} је обрисан", "UserCreatedWithName": "Корисник {0} је направљен", - "User": "Корисник", "TvShows": "ТВ серије", - "System": "Систем", - "Sync": "Усклади", "SubtitleDownloadFailureFromForItem": "Неуспело преузимање титлова за {1} са {0}", "StartupEmbyServerIsLoading": "Џелифин сервер се подиже. Покушајте поново убрзо.", - "Songs": "Песме", "Shows": "Серије", - "ServerNameNeedsToBeRestarted": "{0} треба поново покренути", - "ScheduledTaskStartedWithName": "{0} покренуто", "ScheduledTaskFailedWithName": "{0} неуспело", - "ProviderValue": "Пружалац: {0}", "PluginUpdatedWithName": "{0} ажуриран", "PluginUninstalledWithName": "{0} деинсталиран", "PluginInstalledWithName": "{0} инсталиран", - "Plugin": "Прикључак", - "Playlists": "Листе", "Photos": "Фотографије", "NotificationOptionVideoPlaybackStopped": "Заустављено пуштање видеа", "NotificationOptionVideoPlayback": "Покренуто пуштање видеа", @@ -54,43 +42,26 @@ "Music": "Музика", "Movies": "Филмови", "MixedContent": "Мешовит садржај", - "MessageServerConfigurationUpdated": "Серверска поставка је ажурирана", - "MessageNamedServerConfigurationUpdatedWithValue": "Одељак серверске поставке {0} је ажуриран", - "MessageApplicationUpdatedTo": "Џелифин сервер је ажуриран на {0}", - "MessageApplicationUpdated": "Џелифин сервер је ажуриран", "Latest": "Последње", "LabelRunningTimeValue": "Време рада: {0}", "LabelIpAddressValue": "ИП адреса: {0}", - "ItemRemovedWithName": "{0} уклоњено из библиотеке", - "ItemAddedWithName": "{0} додато у библиотеку", "Inherit": "Наследи", "HomeVideos": "Кућни Видео", - "HeaderRecordingGroups": "Групе снимања", "HeaderNextUp": "Следи", "HeaderLiveTV": "ТВ уживо", - "HeaderFavoriteSongs": "Омиљене песме", "HeaderFavoriteShows": "Омиљене серије", "HeaderFavoriteEpisodes": "Омиљене епизоде", - "HeaderFavoriteArtists": "Омиљени извођачи", - "HeaderFavoriteAlbums": "Омиљени албуми", "HeaderContinueWatching": "Настави гледање", - "HeaderAlbumArtists": "Извођачи албума", "Genres": "Жанрови", "Folders": "Фасцикле", "Favorites": "Омиљено", "FailedLoginAttemptWithUserName": "Неуспели покушај пријавe са {0}", - "DeviceOnlineWithName": "{0} је повезан", - "DeviceOfflineWithName": "{0} је прекинуо везу", "Collections": "Колекције", "ChapterNameValue": "Поглавље {0}", - "Channels": "Канали", - "CameraImageUploadedFrom": "Нова фотографија је учитана са {0}", "Books": "Књиге", "AuthenticationSucceededWithUserName": "{0} Успешна аутентификација", "Artists": "Извођачи", - "Application": "Апликација", "AppDeviceValues": "Апликација: {0}, Уређај: {1}", - "Albums": "Албуми", "TaskDownloadMissingSubtitlesDescription": "Претражује интернет за недостајуће титлове на основу конфигурације метаподатака.", "TaskDownloadMissingSubtitles": "Преузмите недостајуће титлове", "TaskRefreshChannelsDescription": "Освежава информације о интернет каналу.", @@ -135,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 a47ed248e9..7384967122 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -1,41 +1,24 @@ { - "Albums": "Album", "AppDeviceValues": "Applikation: {0}, Enhet: {1}", - "Application": "Applikation", "Artists": "Artister", "AuthenticationSucceededWithUserName": "{0} har autentiserats", "Books": "Böcker", - "CameraImageUploadedFrom": "En ny kamerabild har laddats upp från {0}", - "Channels": "Kanaler", "ChapterNameValue": "Kapitel {0}", "Collections": "Samlingar", - "DeviceOfflineWithName": "{0} har kopplat ned", - "DeviceOnlineWithName": "{0} är ansluten", "FailedLoginAttemptWithUserName": "Misslyckat inloggningsförsök från {0}", "Favorites": "Favoriter", "Folders": "Mappar", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumartister", "HeaderContinueWatching": "Fortsätt titta", - "HeaderFavoriteAlbums": "Favoritalbum", - "HeaderFavoriteArtists": "Favoritartister", "HeaderFavoriteEpisodes": "Favoritavsnitt", "HeaderFavoriteShows": "Favoritserier", - "HeaderFavoriteSongs": "Favoritlåtar", "HeaderLiveTV": "Direktsänd TV", "HeaderNextUp": "Nästa", - "HeaderRecordingGroups": "Inspelningsgrupper", "HomeVideos": "Hemmavideor", "Inherit": "Ärv", - "ItemAddedWithName": "{0} lades till i biblioteket", - "ItemRemovedWithName": "{0} togs bort från biblioteket", "LabelIpAddressValue": "IP-adress: {0}", "LabelRunningTimeValue": "Speltid: {0}", "Latest": "Senaste", - "MessageApplicationUpdated": "Jellyfin Server har uppdaterats", - "MessageApplicationUpdatedTo": "Jellyfin Server har uppdaterats till {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverinställningarna {0} har uppdaterats", - "MessageServerConfigurationUpdated": "Serverkonfigurationen har uppdaterats", "MixedContent": "Blandat innehåll", "Movies": "Filmer", "Music": "Musik", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Videouppspelning har påbörjats", "NotificationOptionVideoPlaybackStopped": "Videouppspelning stoppades", "Photos": "Bilder", - "Playlists": "Spellistor", - "Plugin": "Tillägg", "PluginInstalledWithName": "{0} installerades", "PluginUninstalledWithName": "{0} avinstallerades", "PluginUpdatedWithName": "{0} uppdaterades", - "ProviderValue": "Leverantör: {0}", "ScheduledTaskFailedWithName": "{0} misslyckades", - "ScheduledTaskStartedWithName": "{0} startades", - "ServerNameNeedsToBeRestarted": "{0} behöver startas om", "Shows": "Serier", - "Songs": "Låtar", "StartupEmbyServerIsLoading": "Jellyfin Server arbetar. Pröva igen snart.", "SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} till {1}", - "Sync": "Synk", - "System": "System", "TvShows": "Tv-serier", - "User": "Användare", "UserCreatedWithName": "Användaren {0} har skapats", "UserDeletedWithName": "Användaren {0} har tagits bort", "UserDownloadingItemWithValues": "{0} laddar ner {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} har kopplat ned från {1}", "UserOnlineFromDevice": "{0} är uppkopplad från {1}", "UserPasswordChangedWithName": "Lösenordet för {0} har ändrats", - "UserPolicyUpdatedWithName": "Användarpolicyn har uppdaterats för {0}", "UserStartedPlayingItemWithValues": "{0} spelar {1} på {2}", "UserStoppedPlayingItemWithValues": "{0} har stoppat uppspelningen av {1} på {2}", - "ValueHasBeenAddedToLibrary": "{0} har lagts till i ditt mediebibliotek", - "ValueSpecialEpisodeName": "Specialavsnitt - {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Söker på internet efter saknade undertexter baserat på metadata-konfiguration.", "TaskDownloadMissingSubtitles": "Ladda ner saknade undertexter", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder", "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" + "CleanupUserDataTask": "Uppgift för rensning av användardata", + "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/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index b68af92033..f613b973db 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -1,13 +1,11 @@ { "VersionNumber": "பதிப்பு {0}", - "ValueSpecialEpisodeName": "சிறப்பு - {0}", "TasksMaintenanceCategory": "பராமரிப்பு", "TaskCleanCache": "தற்காலிக சேமிப்பு கோப்பகத்தை சுத்தம் செய்யவும்", "TaskRefreshChapterImages": "அத்தியாயப் படங்களை பிரித்தெடுக்கவும்", "TaskRefreshPeople": "மக்களைப் புதுப்பிக்கவும்", "TaskCleanTranscode": "டிரான்ஸ்கோட் கோப்பகத்தை சுத்தம் செய்யவும்", "TaskRefreshChannelsDescription": "இணையச் சேனல் தகவல்களைப் புதுப்பிக்கிறது.", - "System": "ஒருங்கியம்", "NotificationOptionTaskFailed": "திட்டமிடப்பட்ட பணி தோல்வியடைந்தது", "NotificationOptionPluginUpdateInstalled": "உட்செருகி புதுப்பிக்கப்பட்டது", "NotificationOptionPluginUninstalled": "உட்செருகி நீக்கப்பட்டது", @@ -15,17 +13,10 @@ "NotificationOptionPluginError": "உட்செருகி செயலிழந்தது", "NotificationOptionCameraImageUploaded": "புகைப்படம் பதிவேற்றப்பட்டது", "MixedContent": "கலப்பு உள்ளடக்கங்கள்", - "MessageServerConfigurationUpdated": "சேவையக அமைப்புகள் புதுப்பிக்கப்பட்டன", - "MessageApplicationUpdatedTo": "ஜெல்லிஃபின் சேவையகம் {0} இற்கு புதுப்பிக்கப்பட்டது", - "MessageApplicationUpdated": "ஜெல்லிஃபின் சேவையகம் புதுப்பிக்கப்பட்டது", "Inherit": "மரபுரிமையாகப் பெறு", - "HeaderRecordingGroups": "பதிவு குழுக்கள்", "Folders": "கோப்புறைகள்", "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", - "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது", - "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது", "Collections": "தொகுப்புகள்", - "CameraImageUploadedFrom": "{0} இல் இருந்து புதிய புகைப்படம் பதிவேற்றப்பட்டது", "AppDeviceValues": "செயலி: {0}, சாதனம்: {1}", "TaskDownloadMissingSubtitles": "விடுபட்டுபோன வசன வரிகளைப் பதிவிறக்கு", "TaskRefreshChannels": "சேனல்களை புதுப்பி", @@ -34,27 +25,18 @@ "TasksChannelsCategory": "இணைய சேனல்கள்", "TasksApplicationCategory": "செயலி", "TasksLibraryCategory": "நூலகம்", - "UserPolicyUpdatedWithName": "பயனர் கொள்கை {0} இற்கு புதுப்பிக்கப்பட்டுள்ளது", "UserPasswordChangedWithName": "{0} பயனருக்கு கடவுச்சொல் மாற்றப்பட்டுள்ளது", "UserLockedOutWithName": "பயனர் {0} முடக்கப்பட்டார்", "UserDownloadingItemWithValues": "{0} ஆல் {1} பதிவிறக்கப்படுகிறது", "UserDeletedWithName": "பயனர் {0} நீக்கப்பட்டார்", "UserCreatedWithName": "பயனர் {0} உருவாக்கப்பட்டார்", - "User": "பயனர்", "TvShows": "தொலைக்காட்சித் தொடர்கள்", - "Sync": "ஒத்திசைவு", "StartupEmbyServerIsLoading": "ஜெல்லிஃபின் சேவையகம் துவங்குகிறது. சிறிது நேரம் கழித்து முயற்சிக்கவும்.", - "Songs": "பாடல்கள்", "Shows": "நிகழ்ச்சிகள்", - "ServerNameNeedsToBeRestarted": "{0} மறுதொடக்கம் செய்யப்பட வேண்டும்", - "ScheduledTaskStartedWithName": "{0} துவங்கியது", "ScheduledTaskFailedWithName": "{0} தோல்வியடைந்தது", - "ProviderValue": "வழங்குநர்: {0}", "PluginUpdatedWithName": "{0} புதுப்பிக்கப்பட்டது", "PluginUninstalledWithName": "{0} நீக்கப்பட்டது", "PluginInstalledWithName": "{0} நிறுவப்பட்டது", - "Plugin": "உட்செருகி", - "Playlists": "தொடர் பட்டியல்கள்", "Photos": "புகைப்படங்கள்", "NotificationOptionVideoPlaybackStopped": "நிகழ்பட ஒளிபரப்பு நிறுத்தப்பட்டது", "NotificationOptionVideoPlayback": "நிகழ்பட ஒளிபரப்பு துவங்கியது", @@ -75,28 +57,18 @@ "Latest": "புதியவை", "LabelRunningTimeValue": "ஓடும் நேரம்: {0}", "LabelIpAddressValue": "ஐபி முகவரி: {0}", - "ItemRemovedWithName": "{0} நூலகத்திலிருந்து அகற்றப்பட்டது", - "ItemAddedWithName": "{0} நூலகத்தில் சேர்க்கப்பட்டது", "HeaderNextUp": "அடுத்தது", "HeaderLiveTV": "நேரடித் தொலைக்காட்சி", - "HeaderFavoriteSongs": "பிடித்த பாடல்கள்", "HeaderFavoriteShows": "பிடித்த தொடர்கள்", "HeaderFavoriteEpisodes": "பிடித்த அத்தியாயங்கள்", - "HeaderFavoriteArtists": "பிடித்த கலைஞர்கள்", - "HeaderFavoriteAlbums": "பிடித்த ஆல்பங்கள்", "HeaderContinueWatching": "தொடர்ந்து பார்", - "HeaderAlbumArtists": "கலைஞரின் ஆல்பம்", "Genres": "வகைகள்", "Favorites": "பிடித்தவை", "ChapterNameValue": "அத்தியாயம் {0}", - "Channels": "சேனல்கள்", "Books": "புத்தகங்கள்", "AuthenticationSucceededWithUserName": "{0} வெற்றிகரமாக அங்கீகரிக்கப்பட்டது", "Artists": "கலைஞர்கள்", - "Application": "செயலி", - "Albums": "ஆல்பங்கள்", "NewVersionIsAvailable": "ஜெல்லிஃபின் சேவையகத்தின் புதிய பதிப்பு பதிவிறக்கத்திற்கு கிடைக்கிறது.", - "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது", "TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.", "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது", "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", @@ -108,7 +80,6 @@ "TaskCleanLogs": "பதிவு அடைவை சுத்தம் செய்யுங்கள்", "TaskRefreshLibraryDescription": "புதிய கோப்புகளுக்காக உங்கள் ஊடக நூலகத்தை ஆராய்ந்து மீத்தரவை புதுப்பிக்கும்.", "TaskRefreshChapterImagesDescription": "அத்தியாயங்களைக் கொண்ட வீடியோக்களுக்கான சிறு உருவங்களை உருவாக்குகிறது.", - "ValueHasBeenAddedToLibrary": "உங்கள் மீடியா நூலகத்தில் {0} சேர்க்கப்பட்டது", "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்", "HomeVideos": "முகப்பு வீடியோக்கள்", "UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது", diff --git a/Emby.Server.Implementations/Localization/Core/te.json b/Emby.Server.Implementations/Localization/Core/te.json index ca9e345214..7ac770752e 100644 --- a/Emby.Server.Implementations/Localization/Core/te.json +++ b/Emby.Server.Implementations/Localization/Core/te.json @@ -1,51 +1,31 @@ { - "ValueSpecialEpisodeName": "ప్రత్యేక - {0}", - "Sync": "సమకాలీకరించు", - "Songs": "పాటలు", "Shows": "ప్రదర్శనలు", - "Playlists": "ప్లేజాబితాలు", "Photos": "ఫోటోలు", "MusicVideos": "మ్యూజిక్ వీడియోలు", "Music": "సంగీతం", "Movies": "సినిమాలు", "HeaderContinueWatching": "చూడటం కొనసాగించండి", - "HeaderAlbumArtists": "ఆల్బమ్ కళాకారులు", "Genres": "శైలులు", "Forced": "బలవంతంగా", "Folders": "ఫోల్డర్లు", "Favorites": "ఇష్టమైనవి", "Default": "డిఫాల్ట్", "Collections": "సేకరణలు", - "Channels": "ఛానెల్లు", "Books": "పుస్తకాలు", "Artists": "కళాకారులు", - "Albums": "ఆల్బమ్లు", "HearingImpaired": "వినికిడి లోపం", "HomeVideos": "హోమ్ వీడియోలు", "AppDeviceValues": "అప్లికేషన్ : {0}, పరికరం: {1}", - "Application": "అప్లికేషన్", "AuthenticationSucceededWithUserName": "విజయవంతంగా ఆమోదించబడింది", - "CameraImageUploadedFrom": "{0} నుండి కొత్త కెమెరా చిత్రం అప్లోడ్ చేయబడింది", "ChapterNameValue": "అధ్యాయం", - "DeviceOfflineWithName": "{0} డిస్కనెక్ట్ చేయబడింది", - "DeviceOnlineWithName": "{0} కనెక్ట్ చేయబడింది", "External": "బాహ్య", "FailedLoginAttemptWithUserName": "{0} నుండి విఫలమైన లాగిన్ ప్రయత్నం", - "HeaderFavoriteAlbums": "ఇష్టమైన ఆల్బమ్లు", - "HeaderFavoriteArtists": "ఇష్టమైన కళాకారులు", "HeaderFavoriteEpisodes": "ఇష్టమైన ఎపిసోడ్లు", "HeaderFavoriteShows": "ఇష్టమైన ప్రదర్శనలు", - "HeaderFavoriteSongs": "ఇష్టమైన పాటలు", "HeaderLiveTV": "ప్రత్యక్ష TV", "HeaderNextUp": "తదుపరి", - "HeaderRecordingGroups": "రికార్డింగ్ గుంపులు", - "MessageApplicationUpdated": "జెల్లీఫిన్ సర్వర్ అప్డేట్ చేయడం పూర్తి అయ్యింది", - "MessageApplicationUpdatedTo": "జెల్లీఫిన్ సర్వర్ {0} వెర్షన్ కి అప్డేట్ చెయ్యబడింది", - "MessageServerConfigurationUpdated": "సర్వర్ కన్ఫిగరేషన్ అప్డేట్ చేయబడింది", "NewVersionIsAvailable": "జెల్లీఫిన్ సర్వర్ యొక్క కొత్త వెర్షన్ డౌన్లోడ్ చేసుకోవడానికి అందుబాటులో ఉంది.", "NotificationOptionApplicationUpdateInstalled": "అప్లికేషన్ అప్డేట్ ఇన్స్టాల్ చేయబడింది", - "ItemAddedWithName": "{0} లైబ్రరీకి జోడించబడింది", - "ItemRemovedWithName": "లైబ్రరీ నుండి {0} తీసివేయబడింది", "LabelIpAddressValue": "ఐపీ చిరునామా: {0}", "LabelRunningTimeValue": "నడుస్తున్న సమయం: {0}", "Latest": "తాజా", diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index f0a62646f7..89c2c26748 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -1,10 +1,7 @@ { - "ProviderValue": "ผู้ให้บริการ: {0}", "PluginUpdatedWithName": "อัปเดต {0} แล้ว", "PluginUninstalledWithName": "ถอนการติดตั้ง {0} แล้ว", "PluginInstalledWithName": "ติดตั้ง {0} แล้ว", - "Plugin": "ปลั๊กอิน", - "Playlists": "เพลย์ลิสต์", "Photos": "รูปภาพ", "NotificationOptionVideoPlaybackStopped": "หยุดเล่นวิดีโอ", "NotificationOptionVideoPlayback": "เริ่มเล่นวิดีโอ", @@ -30,48 +27,28 @@ "Music": "ดนตรี", "Movies": "ภาพยนตร์", "MixedContent": "เนื้อหาผสม", - "MessageServerConfigurationUpdated": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์แล้ว", - "MessageNamedServerConfigurationUpdatedWithValue": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์ในส่วน {0} แล้ว", - "MessageApplicationUpdatedTo": "เซิร์ฟเวอร์ Jellyfin ได้รับการอัปเดตเป็น {0}", - "MessageApplicationUpdated": "อัพเดตเซิร์ฟเวอร์ Jellyfin แล้ว", "Latest": "ล่าสุด", "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}", "LabelIpAddressValue": "ที่อยู่ IP: {0}", - "ItemRemovedWithName": "{0} ถูกลบออกจากไลบรารี", - "ItemAddedWithName": "{0} ถูกเพิ่มลงในไลบรารีแล้ว", "Inherit": "สืบทอด", "HomeVideos": "โฮมวิดีโอ", - "HeaderRecordingGroups": "กลุ่มการบันทึก", "HeaderNextUp": "ถัดไป", "HeaderLiveTV": "ทีวีสด", - "HeaderFavoriteSongs": "เพลงที่ชื่นชอบ", "HeaderFavoriteShows": "รายการที่ชื่นชอบ", "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ", - "HeaderFavoriteArtists": "ศิลปินที่ชื่นชอบ", - "HeaderFavoriteAlbums": "อัมบั้มที่ชื่นชอบ", "HeaderContinueWatching": "ดูต่อ", - "HeaderAlbumArtists": "ศิลปินอัลบั้ม", "Genres": "ประเภท", "Folders": "โฟลเดอร์", "Favorites": "รายการโปรด", "FailedLoginAttemptWithUserName": "ความพยายามในการเข้าสู่ระบบล้มเหลวจาก {0}", - "DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว", - "DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว", "Collections": "คอลเลกชัน", "ChapterNameValue": "บทที่ {0}", - "Channels": "ช่อง", - "CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}", "Books": "หนังสือ", "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวตนสำเร็จแล้ว", "Artists": "ศิลปิน", - "Application": "แอปพลิเคชัน", "AppDeviceValues": "แอป: {0}, อุปกรณ์: {1}", - "Albums": "อัลบั้ม", - "ScheduledTaskStartedWithName": "{0} เริ่มต้น", "ScheduledTaskFailedWithName": "{0} ล้มเหลว", - "Songs": "เพลง", "Shows": "รายการ", - "ServerNameNeedsToBeRestarted": "{0} ต้องการการรีสตาร์ท", "TaskDownloadMissingSubtitlesDescription": "ค้นหาคำบรรยายที่หายไปในอินเทอร์เน็ตตามค่ากำหนดในข้อมูลเมตา", "TaskDownloadMissingSubtitles": "ดาวน์โหลดคำบรรยายที่ขาดหายไป", "TaskRefreshChannelsDescription": "รีเฟรชข้อมูลช่องอินเทอร์เน็ต", @@ -95,11 +72,8 @@ "TasksLibraryCategory": "ไลบรารี", "TasksMaintenanceCategory": "ปิดซ่อมบำรุง", "VersionNumber": "เวอร์ชัน {0}", - "ValueSpecialEpisodeName": "พิเศษ - {0}", - "ValueHasBeenAddedToLibrary": "เพิ่ม {0} ลงในไลบรารีสื่อของคุณแล้ว", "UserStoppedPlayingItemWithValues": "{0} เล่นเสร็จแล้ว {1} บน {2}", "UserStartedPlayingItemWithValues": "{0} กำลังเล่น {1} บน {2}", - "UserPolicyUpdatedWithName": "มีการอัปเดตนโยบายผู้ใช้ของ {0}", "UserPasswordChangedWithName": "มีการเปลี่ยนรหัสผ่านของผู้ใช้ {0}", "UserOnlineFromDevice": "{0} ออนไลน์จาก {1}", "UserOfflineFromDevice": "{0} ได้ยกเลิกการเชื่อมต่อจาก {1}", @@ -107,10 +81,7 @@ "UserDownloadingItemWithValues": "{0} กำลังดาวน์โหลด {1}", "UserDeletedWithName": "ลบผู้ใช้ {0} แล้ว", "UserCreatedWithName": "สร้างผู้ใช้ {0} แล้ว", - "User": "ผู้ใช้งาน", "TvShows": "รายการทีวี", - "System": "ระบบ", - "Sync": "ซิงค์", "SubtitleDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดคำบรรยายจาก {0} สำหรับ {1} ได้", "StartupEmbyServerIsLoading": "กำลังโหลดเซิร์ฟเวอร์ Jellyfin โปรดลองอีกครั้งในอีกสักครู่", "Default": "ค่าเริ่มต้น", diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 3789466868..0c42d4a55f 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -1,41 +1,24 @@ { - "Albums": "Albümler", "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}", - "Application": "Uygulama", "Artists": "Sanatçılar", "AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı", "Books": "Kitaplar", - "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", - "Channels": "Kanallar", "ChapterNameValue": "{0}. Bölüm", "Collections": "Koleksiyonlar", - "DeviceOfflineWithName": "{0} bağlantısı kesildi", - "DeviceOnlineWithName": "{0} bağlı", "FailedLoginAttemptWithUserName": "{0} kullanıcısının başarısız oturum açma girişimi", "Favorites": "Favoriler", "Folders": "Klasörler", "Genres": "Türler", - "HeaderAlbumArtists": "Albüm sanatçıları", "HeaderContinueWatching": "İzlemeye Devam Et", - "HeaderFavoriteAlbums": "Favori Albümler", - "HeaderFavoriteArtists": "Favori Sanatçılar", "HeaderFavoriteEpisodes": "Favori Bölümler", "HeaderFavoriteShows": "Favori Diziler", - "HeaderFavoriteSongs": "Favori Şarkılar", "HeaderLiveTV": "Canlı TV", "HeaderNextUp": "Sıradaki Bölümler", - "HeaderRecordingGroups": "Kayıt Grupları", "HomeVideos": "Ana Ekran Videoları", "Inherit": "Devral", - "ItemAddedWithName": "{0} kütüphaneye eklendi", - "ItemRemovedWithName": "{0} kütüphaneden silindi", "LabelIpAddressValue": "IP adresi: {0}", "LabelRunningTimeValue": "Oynatma süresi: {0}", "Latest": "En son", - "MessageApplicationUpdated": "Jellyfin Sunucusu güncellendi", - "MessageApplicationUpdatedTo": "Jellyfin Sunucusu {0} sürümüne güncellendi", - "MessageNamedServerConfigurationUpdatedWithValue": "Sunucu yapılandırma bölümü {0} güncellendi", - "MessageServerConfigurationUpdated": "Sunucu yapılandırması güncellendi", "MixedContent": "Karışık içerik", "Movies": "Filmler", "Music": "Müzik", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "Video oynatma başladı", "NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu", "Photos": "Fotoğraflar", - "Playlists": "Çalma listeleri", - "Plugin": "Eklenti", "PluginInstalledWithName": "{0} yüklendi", "PluginUninstalledWithName": "{0} kaldırıldı", "PluginUpdatedWithName": "{0} güncellendi", - "ProviderValue": "Sağlayıcı: {0}", "ScheduledTaskFailedWithName": "{0} başarısız oldu", - "ScheduledTaskStartedWithName": "{0} başladı", - "ServerNameNeedsToBeRestarted": "{0} yeniden başlatılması gerekiyor", "Shows": "Diziler", - "Songs": "Şarkılar", "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", "SubtitleDownloadFailureFromForItem": "{1} için altyazılar {0} sağlayıcısından indirilemedi", - "Sync": "Eşzamanlama", - "System": "Sistem", "TvShows": "Diziler", - "User": "Kullanıcı", "UserCreatedWithName": "{0} kullanıcısı oluşturuldu", "UserDeletedWithName": "{0} kullanıcısı silindi", "UserDownloadingItemWithValues": "{0} kullanıcısı {1} medyasını indiriyor", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} kullanıcısının {1} ile bağlantısı kesildi", "UserOnlineFromDevice": "{0} kullanıcısı {1} ile çevrimiçi", "UserPasswordChangedWithName": "{0} kullanıcısının parolası değiştirildi", - "UserPolicyUpdatedWithName": "{0} için kullanıcı politikası güncellendi", "UserStartedPlayingItemWithValues": "{0}, {2} cihazında {1} izliyor", "UserStoppedPlayingItemWithValues": "{0}, {2} cihazında {1} izlemeyi bitirdi", - "ValueHasBeenAddedToLibrary": "{0} medya kütüphanenize eklendi", - "ValueSpecialEpisodeName": "Özel - {0}", "VersionNumber": "Sürüm {0}", "TaskCleanCache": "Önbellek Dizinini Temizle", "TasksChannelsCategory": "İnternet Kanalları", @@ -135,5 +106,7 @@ "TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir", "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.", "CleanupUserDataTask": "Kullanıcı verisi temizleme görevi", - "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler." + "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler.", + "LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi", + "Original": "Orijinal" } diff --git a/Emby.Server.Implementations/Localization/Core/ug.json b/Emby.Server.Implementations/Localization/Core/ug.json index 0bcbffb41a..1d5adecb26 100644 --- a/Emby.Server.Implementations/Localization/Core/ug.json +++ b/Emby.Server.Implementations/Localization/Core/ug.json @@ -1,22 +1,15 @@ { "ChapterNameValue": "باب {0}", - "Channels": "قانال", - "CameraImageUploadedFrom": "{0} ئورۇندىن يېڭى سۈرەت چىقىرىلدى", "Books": "كىتاب", "AuthenticationSucceededWithUserName": "{0} تەستىقلاش مۇۋاپىقىيەتلىك بولدى", "Artists": "سەنئەتكار", - "Albums": "پىلاستىنكا", - "DeviceOnlineWithName": "{0} ئۇلاندى", - "DeviceOfflineWithName": "{0} ئۈزۈلدى", "Collections": "توپلام", - "Application": "ئەپ", "AppDeviceValues": "ئەپ: {0}، ئۈسكۈنە: {1}", "HeaderLiveTV": "تور تېلېۋىزىيەسى", "Default": "سۈكۈتتىكى", "Folders": "ھۆججەت خالتىسى", "Favorites": "ساقلىغۇچ", "LabelRunningTimeValue": "ئىجرا بولغان ۋاقتى:{0}", - "HeaderRecordingGroups": "خاتىرلەش گۇرۇپىسى", "Forced": "ئەڭ", "TaskKeyframeExtractor": "ھالقىلىق رامكا ئاجراتقۇچ", "TaskKeyframeExtractorDescription": "سىن ھۆججەتلىرىدىن رامكا ئاجرىتىپ، تېخىمۇ ئېنىق بولغان HLS قويۇلۇش تىزىملىكىنى قۇرۇلىدۇ. بۇ ۋەزىپە ئۇزۇن داۋام قىلىشى مۇمكىن.", @@ -46,35 +39,23 @@ "TasksLibraryCategory": "مېدىيا ئامبىرى", "TasksMaintenanceCategory": "ئاسراش", "VersionNumber": "نەشرى {0}", - "ValueSpecialEpisodeName": "خاسلىق - {0}", - "ValueHasBeenAddedToLibrary": "{0} مېدىيا ئامبىرىڭىزغا قوشۇلدى", "UserStoppedPlayingItemWithValues": "{0}،{1} نى {2} دە قويۇنشتىن توختىدى", "UserStartedPlayingItemWithValues": "{0}،{1} نى {2} دە قويۇۋاتىدۇ", - "UserPolicyUpdatedWithName": "ئابونتلار سىياسىتى {0} غا يېڭىلاندى", "UserPasswordChangedWithName": "ئابونت{0} ئۈچۈن پارول ئۆزگەرتىلدى", "UserOfflineFromDevice": "{0} بىلەن {1} نىڭ ئالاقىسى ئۈزۈلدى", "UserLockedOutWithName": "ئابونت {0} قۇلۇپلاندى", "UserDownloadingItemWithValues": "{0} چۈشۈرۈۋاتىدۇ {1}", "UserDeletedWithName": "{0} ئابونت ئۆچۈرۈلدى", "UserCreatedWithName": "{0} ئابونت يېڭىدىن قوشۇلدى", - "User": "ئابونت", "Undefined": "بېكىتىلمىگەن", "TvShows": "تىياتىرلار", - "System": "سىستېما", - "Sync": "ماس قەدەمدەش", "SubtitleDownloadFailureFromForItem": "{0} دىن {0} نىڭ فىلىم خېتىنى چۈشۈرگىلى بولمىدى", "StartupEmbyServerIsLoading": "Jellyfin مۇلازىمىتېرى يۈكلىنىۋاتىدۇ. سەل تۇرۇپ قايتا سىناڭ.", - "Songs": "ناخشىلار", "Shows": "پروگراممىلار", - "ServerNameNeedsToBeRestarted": "{0} قايتا قوزغىتىلىشى كېرەك", - "ScheduledTaskStartedWithName": "{0} باشلاندى", "ScheduledTaskFailedWithName": "{0} مەغلۇپ بولدى", - "ProviderValue": "تەمىنلىگۈچى: {0}", "PluginUpdatedWithName": "{0} يېڭىلاندى", "PluginUninstalledWithName": "{0} ئۆچۈرۈلدى", "PluginInstalledWithName": "{0} قاچىلاندى", - "Plugin": "قىستۇرما", - "Playlists": "قويۇش تىزىملىكى", "Photos": "رەسىملەر", "NotificationOptionVideoPlaybackStopped": "سىن قويۇلۇش توختىدى", "NotificationOptionVideoPlayback": "سىن قويۇلدى", @@ -100,24 +81,14 @@ "Music": "مۇزىكا", "Movies": "فىلىملەر", "MixedContent": "ئارىلاشما مەزمۇن", - "MessageNamedServerConfigurationUpdatedWithValue": "مۇلازىمىتېر تەڭشىكىنىڭ {0} قىسمى يېڭىلىنىپ بولدى", - "MessageServerConfigurationUpdated": "مۇلازىمىتېر يېڭىلىنىپ بولدى", - "MessageApplicationUpdated": "Jellyfin مۇلازىمىتېرى يېڭىلاندى", - "MessageApplicationUpdatedTo": "Jellyfin مۇلازىمىتېر نەشرى {0} گە يېڭىلاندى", "Latest": "ئەڭ يېڭى", "LabelIpAddressValue": "{0}: IP ئادرىسى", - "ItemRemovedWithName": "{0} ئامباردىن چىقىرىلدى", - "ItemAddedWithName": "{0} ئامبارغا قوشۇلدى", "Inherit": "داۋاملاشتۇرۇش", "HomeVideos": "ئائىلە سىنلىرى", "HeaderNextUp": "كېيىنكىسى", - "HeaderFavoriteSongs": "ئەڭ ياقتۇرىدىغان ناخشىلار", "HeaderFavoriteShows": "ئەڭ ياقتۇرىدىغان پروگراممىلار", "HeaderFavoriteEpisodes": "ئەڭ ياقتۇرىدىغان تىياتېرلار", - "HeaderFavoriteArtists": "ئەڭ ياقتۇرىدىغان سەنئەتكارلار", - "HeaderFavoriteAlbums": "ياقتۇرىدىغان پىلاستىنكىلار", "HeaderContinueWatching": "داۋاملىق كۆرۈش", - "HeaderAlbumArtists": "پىلاستىنكا سەنئەتكارلىرى", "Genres": "ئۇسلۇبلار", "FailedLoginAttemptWithUserName": "{0} كىرىش ئوڭۇشلۇق بولمىدى", "External": "سىرتقى" diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 9246d9de20..ccb9d915d1 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -2,36 +2,22 @@ "MusicVideos": "Відеокліпи", "Music": "Музика", "Movies": "Фільми", - "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}", - "MessageApplicationUpdated": "Jellyfin Server оновлено", "Latest": "Останні", "LabelIpAddressValue": "IP-адреса: {0}", - "ItemRemovedWithName": "{0} видалено з медіатеки", - "ItemAddedWithName": "{0} додано до медіатеки", "HeaderNextUp": "Наступний", "HeaderLiveTV": "Ефірне ТБ", - "HeaderFavoriteSongs": "Обрані пісні", "HeaderFavoriteShows": "Обрані шоу", "HeaderFavoriteEpisodes": "Обрані епізоди", - "HeaderFavoriteArtists": "Обрані виконавці", - "HeaderFavoriteAlbums": "Обрані альбоми", "HeaderContinueWatching": "Продовжити перегляд", - "HeaderAlbumArtists": "Виконавці альбому", "Genres": "Жанри", "Folders": "Теки", "Favorites": "Обрані", - "DeviceOnlineWithName": "Пристрій {0} підключився", - "DeviceOfflineWithName": "Пристрій {0} відключився", "Collections": "Колекції", "ChapterNameValue": "Сцена {0}", - "Channels": "Канали", - "CameraImageUploadedFrom": "Нову фотографію завантажено з {0}", "Books": "Книги", "AuthenticationSucceededWithUserName": "{0} успішно авторизовано", "Artists": "Виконавці", - "Application": "Додаток", "AppDeviceValues": "Додаток: {0}, Пристрій: {1}", - "Albums": "Альбоми", "NotificationOptionServerRestartRequired": "Необхідно перезапустити сервер", "NotificationOptionPluginUpdateInstalled": "Встановлено оновлення плагіна", "NotificationOptionPluginUninstalled": "Плагін видалено", @@ -64,11 +50,8 @@ "TasksLibraryCategory": "Медіатека", "TasksMaintenanceCategory": "Обслуговування", "VersionNumber": "Версія {0}", - "ValueSpecialEpisodeName": "Спецепізод - {0}", - "ValueHasBeenAddedToLibrary": "{0} додано до медіатеки", "UserStoppedPlayingItemWithValues": "{0} закінчив відтворення {1} на {2}", "UserStartedPlayingItemWithValues": "{0} відтворює {1} на {2}", - "UserPolicyUpdatedWithName": "Політика користувача оновлена для {0}", "UserPasswordChangedWithName": "Пароль змінено для користувача {0}", "UserOnlineFromDevice": "{0} підключився з {1}", "UserOfflineFromDevice": "{0} відключився від {1}", @@ -76,23 +59,14 @@ "UserDownloadingItemWithValues": "{0} завантажує {1}", "UserDeletedWithName": "Користувача {0} видалено", "UserCreatedWithName": "Користувача {0} створено", - "User": "Користувач", "TvShows": "ТВ-шоу", - "System": "Система", - "Sync": "Синхронізація", "SubtitleDownloadFailureFromForItem": "Не вдалося завантажити субтитри з {0} для {1}", "StartupEmbyServerIsLoading": "Jellyfin Server завантажується. Будь ласка, спробуйте трішки пізніше.", - "Songs": "Пісні", "Shows": "Серіали", - "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити", - "ScheduledTaskStartedWithName": "{0} розпочато", "ScheduledTaskFailedWithName": "{0} незавершено, збій", - "ProviderValue": "Постачальник: {0}", "PluginUpdatedWithName": "{0} оновлено", "PluginUninstalledWithName": "{0} видалено", "PluginInstalledWithName": "{0} встановлено", - "Plugin": "Плагін", - "Playlists": "Плейлисти", "Photos": "Фотографії", "NotificationOptionVideoPlaybackStopped": "Відтворення відео зупинено", "NotificationOptionVideoPlayback": "Розпочато відтворення відео", @@ -109,10 +83,7 @@ "NameSeasonNumber": "Сезон {0}", "NameInstallFailed": "Не вдалося встановити {0}", "MixedContent": "Змішаний контент", - "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена", - "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено", "Inherit": "Успадкувати", - "HeaderRecordingGroups": "Групи запису", "Forced": "Форсовані", "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.", "TaskCleanActivityLog": "Очистити журнал активності", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImages": "Змінити місце розташування прев'ю-зображень", "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.", "CleanupUserDataTask": "Завдання очищення даних користувача", - "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому." + "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.", + "Original": "Оригінал", + "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/ur.json b/Emby.Server.Implementations/Localization/Core/ur.json index 94d9c8541e..07d309c270 100644 --- a/Emby.Server.Implementations/Localization/Core/ur.json +++ b/Emby.Server.Implementations/Localization/Core/ur.json @@ -1,16 +1,10 @@ { "Books": "کتابیں", "AppDeviceValues": "ایپ: {0}، ڈیوائس: {1}", - "Albums": "البمز", - "Application": "ایپلی کیشن", "Artists": "فنکار", "AuthenticationSucceededWithUserName": "{0} کی کامیابی سے تصدیق ہو چکی ہے", - "CameraImageUploadedFrom": "ایک نئی کیمرے کی تصویر {0} سے اپ لوڈ کی گئی ہے", - "Channels": "چینلز", "ChapterNameValue": "باب {0}", "Collections": "مجموعے", "Default": "ڈیفالٹ", - "DeviceOfflineWithName": "{0} نے رابطہ منقطع کر دیا ہے", - "DeviceOnlineWithName": "{0} منسلک ہے", "External": "بیرونی" } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index f6539adff3..b3f24a31e3 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -1,27 +1,17 @@ { - "HeaderFavoriteAlbums": "پسندیدہ البمز", "HeaderNextUp": "اگلا", - "HeaderFavoriteArtists": "پسندیدہ فنکار", - "HeaderAlbumArtists": "البم کے فنکار", "Movies": "فلمیں", "HeaderFavoriteEpisodes": "پسندیدہ اقساط", "Collections": "مجموعے", "Folders": "فولڈرز", "HeaderLiveTV": "براہ راست ٹی وی", - "Channels": "چینلز", "HeaderContinueWatching": "دیکھنا جاری رکھیں", - "Playlists": "پلے لسٹس", - "ValueSpecialEpisodeName": "خصوصی - {0}", "Shows": "دکھاتا ہے", "Genres": "انواع", "Artists": "فنکار", - "Sync": "مطابقت پذیری", "Photos": "تصاویر", - "Albums": "البمز", "Favorites": "پسندیدہ", - "Songs": "گانے", "Books": "کتابیں", - "HeaderFavoriteSongs": "پسندیدہ گانے", "HeaderFavoriteShows": "پسندیدہ شوز", "TaskDownloadMissingSubtitlesDescription": "میٹا ڈیٹا کی تشکیل پر مبنی ذیلی عنوانات کے غائب عنوانات انٹرنیٹ پے تلاش کرتا ہے۔", "TaskDownloadMissingSubtitles": "غائب سب ٹائٹلز ڈاؤن لوڈ کریں", @@ -46,10 +36,8 @@ "TasksLibraryCategory": "لآیبریری", "TasksMaintenanceCategory": "مرمت", "VersionNumber": "ورژن {0}", - "ValueHasBeenAddedToLibrary": "{0} آپ کی میڈیا لائبریری میں شامل کر دیا گیا ہے", "UserStoppedPlayingItemWithValues": "{0} نے {1} چلانا ختم کر دیا ھے {2} پے", "UserStartedPlayingItemWithValues": "{0} چلا رہا ہے {1} {2} پے", - "UserPolicyUpdatedWithName": "صارف {0} کی پالیسی کیلئے تازہ کاری کی گئی ہے", "UserPasswordChangedWithName": "صارف {0} کے لئے پاس ورڈ تبدیل کر دیا گیا ہے", "UserOnlineFromDevice": "{0} آن لائن ہے {1} سے", "UserOfflineFromDevice": "{0} سے منقطع ہوگیا ہے {1}", @@ -57,19 +45,13 @@ "UserDownloadingItemWithValues": "{0} ڈاؤن لوڈ کر رھا ھے {1}", "UserDeletedWithName": "صارف {0} کو ہٹا دیا گیا ہے", "UserCreatedWithName": "صارف {0} تشکیل دیا گیا ہے", - "User": "صارف", "TvShows": "ٹی وی کے پروگرام", - "System": "نظام", "SubtitleDownloadFailureFromForItem": "ذیلی عنوانات {0} سے ڈاؤن لوڈ کرنے میں ناکام {1} کے لیے", "StartupEmbyServerIsLoading": "جیلیفن سرور لوڈ ہورہا ہے۔ براہ کرم جلد ہی دوبارہ کوشش کریں۔", - "ServerNameNeedsToBeRestarted": "{0} دوبارہ چلانے کرنے کی ضرورت ہے", - "ScheduledTaskStartedWithName": "{0} شروع", "ScheduledTaskFailedWithName": "{0} ناکام", - "ProviderValue": "فراہم کرنے والا: {0}", "PluginUpdatedWithName": "{0} تازہ کاری کی گئی تھی", "PluginUninstalledWithName": "[0} ہٹا دیا گیا تھا", "PluginInstalledWithName": "{0} انسٹال کیا گیا تھا", - "Plugin": "پلگن", "NotificationOptionVideoPlaybackStopped": "ویڈیو پلے بیک رک گیا", "NotificationOptionVideoPlayback": "ویڈیو پلے بیک شروع ہوا", "NotificationOptionUserLockedOut": "صارف کو لاک آؤٹ کیا گیا", @@ -93,25 +75,14 @@ "MusicVideos": "میوزک ویڈیوز", "Music": "موسیقی", "MixedContent": "مخلوط مواد", - "MessageServerConfigurationUpdated": "سرور کو اپ ڈیٹ کر دیا گیا ہے", - "MessageNamedServerConfigurationUpdatedWithValue": "سرور ضمن {0} کو ترتیب دے دیا گیا ھے", - "MessageApplicationUpdatedTo": "جیلیفن سرور کو اپ ڈیٹ کیا ہے {0}", - "MessageApplicationUpdated": "جیلیفن سرور کو اپ ڈیٹ کر دیا گیا ہے", "Latest": "تازہ ترین", "LabelRunningTimeValue": "چلانے کی مدت", "LabelIpAddressValue": "آئ پی ایڈریس {0}", - "ItemRemovedWithName": "لائبریری سے ہٹا دیا گیا ھے", - "ItemAddedWithName": "[0} لائبریری میں شامل کیا گیا ھے", "Inherit": "وراثت", "HomeVideos": "ہوم ویڈیوز", - "HeaderRecordingGroups": "ریکارڈنگ گروپس", "FailedLoginAttemptWithUserName": "{0} سے لاگ ان کی ناکام کوشش", - "DeviceOnlineWithName": "{0} متصل ھو چکا ھے", - "DeviceOfflineWithName": "{0} منقطع ھو چکا ھے", "ChapterNameValue": "باب", "AuthenticationSucceededWithUserName": "{0} کامیابی کے ساتھ تصدیق ھوچکی ھے", - "CameraImageUploadedFrom": "ایک نئی کیمرہ تصویر اپ لوڈ کی گئی ہے {0}", - "Application": "پروگرام", "AppDeviceValues": "پروگرام:{0}, ڈیوائس:{1}", "Forced": "جَبری", "Undefined": "غير وضاحتى", diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json index e44b3f5167..3215733c1a 100644 --- a/Emby.Server.Implementations/Localization/Core/uz.json +++ b/Emby.Server.Implementations/Localization/Core/uz.json @@ -1,47 +1,32 @@ { "HeaderContinueWatching": "Ko‘rishda davom etish", - "HeaderAlbumArtists": "Albom ijrochilari", "Genres": "Janrlar", "Folders": "Jildlar", "Favorites": "Sevimlilar", "Collections": "To'plamlar", - "Channels": "Kanallar", "Books": "Kitoblar", "Artists": "Ijrochilar", - "Albums": "Albomlar", "AuthenticationSucceededWithUserName": "{0} muvaffaqiyatli tasdiqlandi", "AppDeviceValues": "Ilova: {0}, Qurilma: {1}", - "Application": "Ilova", - "CameraImageUploadedFrom": "{0}dan yangi kamera rasmi yuklandi", - "DeviceOnlineWithName": "{0} ulangan", - "ItemRemovedWithName": "{0} kutbxonadan o'chirildi", "External": "Tashqi", "FailedLoginAttemptWithUserName": "Muvafaqiyatsiz kirishlar soni {0}", "Forced": "Majburiy", "ChapterNameValue": "{0}chi bo'lim", - "DeviceOfflineWithName": "{0} aloqa uzildi", "HeaderLiveTV": "Jonli TV", "HeaderNextUp": "Keyingisi", - "ItemAddedWithName": "{0} kutbxonaga qo'shildi", "LabelIpAddressValue": "IP manzil: {0}", "SubtitleDownloadFailureFromForItem": "{0} dan {1} uchun taglavhalarni yuklab boʻlmadi", "UserPasswordChangedWithName": "Foydalanuvchi {0} paroli oʻzgartirildi", - "ValueHasBeenAddedToLibrary": "{0} kutubxonaga qoʻshildi", "TaskCleanActivityLogDescription": "Belgilangan yoshdan kattaroq faoliyat jurnali yozuvlarini oʻchiradi.", "TaskAudioNormalization": "Ovozni normallashtirish", "TaskRefreshLibraryDescription": "Media kutubxonasi yangi fayllar uchun skanerlanmoqda va metama'lumotlar yangilanmoqda.", "Default": "Joriy", - "HeaderFavoriteAlbums": "Tanlangan albomlar", - "HeaderFavoriteArtists": "Tanlangan artistlar", "HeaderFavoriteEpisodes": "Tanlangan epizodlar", "HeaderFavoriteShows": "Tanlangan shoular", - "HeaderFavoriteSongs": "Tanlangan qo'shiqlar", - "HeaderRecordingGroups": "Yozuvlar guruhi", "HomeVideos": "Uy videolari", "NotificationOptionVideoPlaybackStopped": "Video ijrosi toʻxtatildi", "TvShows": "TV seriallar", "Undefined": "Belgilanmagan", - "User": "Foydalanuvchi", "UserCreatedWithName": "{0} foydalanuvchi yaratildi", "TaskCleanCacheDescription": "Tizimga kerak bo'lmagan kesh fayllari o'chiriladi.", "TaskAudioNormalizationDescription": "Ovozni normallashtirish ma'lumotlari uchun fayllarni skanerlaydi.", @@ -67,10 +52,6 @@ "NotificationOptionVideoPlayback": "Video ijrosi boshlandi", "Photos": "Surat", "Latest": "So'ngi", - "MessageApplicationUpdated": "Jellyfin Server yangilandi", - "MessageApplicationUpdatedTo": "Jellyfin Server {0} gacha yangilandi", - "MessageNamedServerConfigurationUpdatedWithValue": "Server konfiguratsiyasi ({0}-boʻlim) yangilandi", - "MessageServerConfigurationUpdated": "Server konfiguratsiyasi yangilandi", "MixedContent": "Aralashgan tarkib", "Movies": "Kinolar", "Music": "Qo'shiqlar", @@ -78,29 +59,19 @@ "NameInstallFailed": "Omadsiz ornatish {0}", "NameSeasonNumber": "{0} Fasl", "NameSeasonUnknown": "Fasl aniqlanmagan", - "Playlists": "Pleylistlar", "NewVersionIsAvailable": "Yuklab olish uchun Jellyfin Server ning yangi versiyasi mavjud", - "Plugin": "Plagin", "TaskCleanLogs": "Jurnallar katalogini tozalash", "PluginUpdatedWithName": "{0} - yangilandi", - "ProviderValue": "Yetkazib beruvchi: {0}", "ScheduledTaskFailedWithName": "{0} - omadsiz", - "ScheduledTaskStartedWithName": "{0} - ishga tushirildi", - "ServerNameNeedsToBeRestarted": "Qayta yuklash kerak {0}", "Shows": "Teleko'rsatuv", - "Songs": "Kompozitsiyalar", "StartupEmbyServerIsLoading": "Jellyfin Server yuklanmoqda. Tez orada qayta urinib koʻring.", - "Sync": "Sinxronizatsiya", - "System": "Tizim", "UserDeletedWithName": "{0} foydalanuvchisi oʻchirib tashlandi", "UserDownloadingItemWithValues": "{0} yuklanmoqda {1}", "UserLockedOutWithName": "{0} foydalanuvchisi bloklandi", "UserOfflineFromDevice": "{0} {1}dan uzildi", "UserOnlineFromDevice": "{0} {1} dan ulandi", - "UserPolicyUpdatedWithName": "{0} foydalanuvchisining siyosatlari yangilandi", "UserStartedPlayingItemWithValues": "{0} - {2} da \"{1}\" ijrosi", "UserStoppedPlayingItemWithValues": "{0} - ijro etish to‘xtatildi {1} {2}", - "ValueSpecialEpisodeName": "Maxsus qism – {0}", "VersionNumber": "Versiya {0}", "TasksMaintenanceCategory": "Xizmat ko'rsatish", "TasksLibraryCategory": "Media kutubxona", @@ -111,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/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 947a2c80de..2ba665e2ff 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -3,17 +3,11 @@ "Favorites": "Yêu Thích", "Folders": "Thư Mục", "Genres": "Thể Loại", - "HeaderAlbumArtists": "Album nghệ sĩ", "HeaderContinueWatching": "Xem Tiếp", "HeaderLiveTV": "TV Trực Tiếp", "Movies": "Phim", "Photos": "Ảnh", - "Playlists": "Danh sách phát", "Shows": "Chương Trình TV", - "Songs": "Bài Hát", - "Sync": "Đồng Bộ", - "ValueSpecialEpisodeName": "Đặc Biệt - {0}", - "Albums": "Album", "Artists": "Ca Sĩ", "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.", "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu", @@ -38,10 +32,8 @@ "TasksLibraryCategory": "Thư Viện", "TasksMaintenanceCategory": "Bảo Trì", "VersionNumber": "Phiên Bản {0}", - "ValueHasBeenAddedToLibrary": "{0} đã được thêm vào thư viện của bạn", "UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}", "UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}", - "UserPolicyUpdatedWithName": "Chính sách người dùng đã được cập nhật cho {0}", "UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}", "UserOnlineFromDevice": "{0} trực tuyến từ {1}", "UserOfflineFromDevice": "{0} đã ngắt kết nối từ {1}", @@ -49,19 +41,13 @@ "UserDownloadingItemWithValues": "{0} đang tải xuống {1}", "UserDeletedWithName": "Người Dùng {0} đã được xóa", "UserCreatedWithName": "Người Dùng {0} đã được tạo", - "User": "Người Dùng", "TvShows": "Chương Trình TV", - "System": "Hệ Thống", "SubtitleDownloadFailureFromForItem": "Không thể tải xuống phụ đề từ {0} cho {1}", "StartupEmbyServerIsLoading": "Jellyfin Server đang tải. Vui lòng thử lại trong thời gian ngắn.", - "ServerNameNeedsToBeRestarted": "{0} cần được khởi động lại", - "ScheduledTaskStartedWithName": "{0} đã bắt đầu", "ScheduledTaskFailedWithName": "{0} đã thất bại", - "ProviderValue": "Provider: {0}", "PluginUpdatedWithName": "{0} đã cập nhật", "PluginUninstalledWithName": "{0} đã được gỡ bỏ", "PluginInstalledWithName": "{0} đã được cài đặt", - "Plugin": "Plugin", "NotificationOptionVideoPlaybackStopped": "Đã dừng phát lại video", "NotificationOptionVideoPlayback": "Đã bắt đầu phát lại video", "NotificationOptionUserLockedOut": "Người dùng bị khóa", @@ -85,33 +71,18 @@ "MusicVideos": "Videos Nhạc", "Music": "Nhạc", "MixedContent": "Nội dung hỗn hợp", - "MessageServerConfigurationUpdated": "Cấu hình máy chủ đã được cập nhật", - "MessageNamedServerConfigurationUpdatedWithValue": "Phần cấu hình máy chủ {0} đã được cập nhật", - "MessageApplicationUpdatedTo": "Jellyfin Server đã được cập nhật lên {0}", - "MessageApplicationUpdated": "Jellyfin Server đã được cập nhật", "Latest": "Gần Nhất", "LabelRunningTimeValue": "Thời Gian Chạy: {0}", "LabelIpAddressValue": "Địa chỉ IP: {0}", - "ItemRemovedWithName": "{0} đã xóa khỏi thư viện", - "ItemAddedWithName": "{0} được thêm vào thư viện", "Inherit": "Thừa hưởng", "HomeVideos": "Video Nhà", - "HeaderRecordingGroups": "Nhóm Ghi Video", "HeaderNextUp": "Tiếp Theo", - "HeaderFavoriteSongs": "Bài Hát Yêu Thích", "HeaderFavoriteShows": "Chương Trình Yêu Thích", "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", - "HeaderFavoriteArtists": "Nghệ Sĩ Yêu Thích", - "HeaderFavoriteAlbums": "Album Ưa Thích", "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}", - "DeviceOnlineWithName": "{0} đã kết nối", - "DeviceOfflineWithName": "{0} đã ngắt kết nối", "ChapterNameValue": "Phân Cảnh {0}", - "Channels": "Kênh", - "CameraImageUploadedFrom": "Một hình ảnh máy ảnh mới đã được tải lên từ {0}", "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", - "Application": "Ứng Dụng", "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}", "TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.", "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động", @@ -135,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.", "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện", "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng", - "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày." + "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.", + "Original": "Gốc", + "LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 6a7b7fb4e6..18418ae0bc 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -1,41 +1,24 @@ { - "Albums": "专辑", "AppDeviceValues": "应用:{0},设备:{1}", - "Application": "应用程序", "Artists": "艺术家", "AuthenticationSucceededWithUserName": "{0} 认证成功", "Books": "书籍", - "CameraImageUploadedFrom": "已从 {0} 上传新的相机照片", - "Channels": "频道", "ChapterNameValue": "章节 {0}", "Collections": "合集", - "DeviceOfflineWithName": "{0} 已断开连接", - "DeviceOnlineWithName": "{0} 已连接", "FailedLoginAttemptWithUserName": "来自 {0} 的登录失败", "Favorites": "收藏夹", "Folders": "文件夹", "Genres": "类型", - "HeaderAlbumArtists": "专辑艺术家", "HeaderContinueWatching": "继续观看", - "HeaderFavoriteAlbums": "收藏的专辑", - "HeaderFavoriteArtists": "收藏的艺术家", "HeaderFavoriteEpisodes": "收藏的剧集", "HeaderFavoriteShows": "收藏的节目", - "HeaderFavoriteSongs": "收藏的歌曲", "HeaderLiveTV": "电视直播", "HeaderNextUp": "接下来播放", - "HeaderRecordingGroups": "录制组", "HomeVideos": "家庭视频", "Inherit": "继承", - "ItemAddedWithName": "{0} 已添加到媒体库", - "ItemRemovedWithName": "{0} 已从媒体库移除", "LabelIpAddressValue": "IP 地址:{0}", "LabelRunningTimeValue": "运行时间:{0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin 服务器已更新", - "MessageApplicationUpdatedTo": "Jellyfin 服务器版本已更新到 {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "服务器配置 {0} 部分已更新", - "MessageServerConfigurationUpdated": "服务器配置已更新", "MixedContent": "混合内容", "Movies": "电影", "Music": "音乐", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "视频已开始播放", "NotificationOptionVideoPlaybackStopped": "视频播放已停止", "Photos": "照片", - "Playlists": "播放列表", - "Plugin": "插件", "PluginInstalledWithName": "{0} 已安装", "PluginUninstalledWithName": "{0} 已卸载", "PluginUpdatedWithName": "{0} 已更新", - "ProviderValue": "提供商:{0}", "ScheduledTaskFailedWithName": "{0} 已失败", - "ScheduledTaskStartedWithName": "{0} 已开始", - "ServerNameNeedsToBeRestarted": "{0} 需要重新启动", "Shows": "节目", - "Songs": "歌曲", "StartupEmbyServerIsLoading": "Jellyfin 服务器正在启动,请稍后再试。", "SubtitleDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的字幕", - "Sync": "同步", - "System": "系统", "TvShows": "电视剧", - "User": "用户", "UserCreatedWithName": "已创建用户 {0}", "UserDeletedWithName": "已删除用户 {0}", "UserDownloadingItemWithValues": "{0} 正在下载 {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} 已从 {1} 断开", "UserOnlineFromDevice": "{0} 已在 {1} 上线", "UserPasswordChangedWithName": "用户 {0} 的密码已更改", - "UserPolicyUpdatedWithName": "用户协议已更新为 {0}", "UserStartedPlayingItemWithValues": "{0} 在 {2} 上开始播放 {1}", "UserStoppedPlayingItemWithValues": "{0} 在 {2} 上停止播放 {1}", - "ValueHasBeenAddedToLibrary": "{0} 已添加至您的媒体库中", - "ValueSpecialEpisodeName": "特典 - {0}", "VersionNumber": "版本 {0}", "TaskUpdatePluginsDescription": "为已设置为自动更新的插件下载和安装更新。", "TaskRefreshPeople": "刷新演职人员", @@ -135,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 f3ad8be2a8..b2fcbc93a2 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -1,41 +1,24 @@ { - "Albums": "專輯", "AppDeviceValues": "程式:{0},裝置:{1}", - "Application": "應用程式", "Artists": "藝人", "AuthenticationSucceededWithUserName": "{0} 成功通過驗證", "Books": "書籍", - "CameraImageUploadedFrom": "{0} 已經成功上載咗一張新相", - "Channels": "頻道", "ChapterNameValue": "第 {0} 章", "Collections": "系列", - "DeviceOfflineWithName": "{0} 斷開咗連線", - "DeviceOnlineWithName": "{0} 連線咗", "FailedLoginAttemptWithUserName": "來自 {0} 嘅登入嘗試失敗咗", "Favorites": "心水", "Folders": "資料夾", "Genres": "風格", - "HeaderAlbumArtists": "專輯歌手", "HeaderContinueWatching": "繼續睇返", - "HeaderFavoriteAlbums": "心水嘅專輯", - "HeaderFavoriteArtists": "心水嘅藝人", "HeaderFavoriteEpisodes": "心水嘅劇集", "HeaderFavoriteShows": "心水嘅節目", - "HeaderFavoriteSongs": "心水嘅歌曲", "HeaderLiveTV": "電視直播", "HeaderNextUp": "跟住落嚟", - "HeaderRecordingGroups": "錄製組", "HomeVideos": "家庭影片", "Inherit": "繼承", - "ItemAddedWithName": "{0} 經已加咗入媒體櫃", - "ItemRemovedWithName": "{0} 經已由媒體櫃移除咗", "LabelIpAddressValue": "IP 位址:{0}", "LabelRunningTimeValue": "運行時間:{0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin 經已更新咗", - "MessageApplicationUpdatedTo": "Jellyfin 已經更新到 {0} 版本", - "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定「{0}」經已更新咗", - "MessageServerConfigurationUpdated": "伺服器設定經已更新咗", "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", @@ -61,23 +44,14 @@ "NotificationOptionVideoPlayback": "開始播放影片", "NotificationOptionVideoPlaybackStopped": "停咗播放影片", "Photos": "相片", - "Playlists": "播放清單", - "Plugin": "外掛程式", "PluginInstalledWithName": "裝好咗 {0}", "PluginUninstalledWithName": "剷走咗 {0}", "PluginUpdatedWithName": "更新好咗 {0}", - "ProviderValue": "提供者:{0}", "ScheduledTaskFailedWithName": "{0} 執行失敗", - "ScheduledTaskStartedWithName": "開始執行 {0}", - "ServerNameNeedsToBeRestarted": "{0} 需要重新啟動", "Shows": "節目", - "Songs": "歌曲", "StartupEmbyServerIsLoading": "Jellyfin 伺服器載入緊,唔該稍後再試。", "SubtitleDownloadFailureFromForItem": "經 {0} 下載 {1} 嘅字幕失敗咗", - "Sync": "同步", - "System": "系統", "TvShows": "電視節目", - "User": "使用者", "UserCreatedWithName": "經已建立咗新使用者 {0}", "UserDeletedWithName": "使用者 {0} 經已被刪走", "UserDownloadingItemWithValues": "{0} 下載緊 {1}", @@ -85,11 +59,8 @@ "UserOfflineFromDevice": "{0} 經已由 {1} 斷開咗連線", "UserOnlineFromDevice": "{0} 正喺 {1} 連線", "UserPasswordChangedWithName": "使用者 {0} 嘅密碼經已更改咗", - "UserPolicyUpdatedWithName": "使用者 {0} 嘅權限經已更新咗", "UserStartedPlayingItemWithValues": "{0} 正喺 {2} 播緊 {1}", "UserStoppedPlayingItemWithValues": "{0} 已經喺 {2} 停止播放 {1}", - "ValueHasBeenAddedToLibrary": "{0} 已經成功加入咗你嘅媒體櫃", - "ValueSpecialEpisodeName": "特輯 - {0}", "VersionNumber": "版本 {0}", "TaskDownloadMissingSubtitles": "下載漏咗嘅字幕", "TaskUpdatePlugins": "更新外掛程式", @@ -106,8 +77,8 @@ "TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。", "TaskCleanLogs": "清理日誌資料夾", "TaskRefreshLibrary": "掃描媒體櫃", - "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。", - "TaskRefreshChapterImages": "擷取章節圖片", + "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。", + "TaskRefreshChapterImages": "擷取章節圖像", "TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。", "TaskCleanCache": "清理快取(Cache)資料夾", "TasksChannelsCategory": "網路頻道", @@ -135,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 1caf887094..5dace3b0b7 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -1,39 +1,23 @@ { - "Albums": "專輯", "AppDeviceValues": "應用程式:{0},裝置:{1}", - "Application": "應用程式", "Artists": "藝人", "AuthenticationSucceededWithUserName": "成功授權 {0}", "Books": "書籍", - "CameraImageUploadedFrom": "已從 {0} 成功上傳一張照片", - "Channels": "頻道", "ChapterNameValue": "章節 {0}", "Collections": "系列作", - "DeviceOfflineWithName": "{0} 已中斷連接", - "DeviceOnlineWithName": "{0} 已連接", "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗嘗試", "Favorites": "我的最愛", "Folders": "資料夾", "Genres": "風格", - "HeaderAlbumArtists": "專輯演出者", "HeaderContinueWatching": "繼續觀看", - "HeaderFavoriteAlbums": "最愛專輯", - "HeaderFavoriteArtists": "最愛的藝人", "HeaderFavoriteEpisodes": "最愛的劇集", "HeaderFavoriteShows": "最愛的節目", - "HeaderFavoriteSongs": "最愛的歌曲", "HeaderLiveTV": "電視直播", "HeaderNextUp": "接下來", "HomeVideos": "家庭影片", - "ItemAddedWithName": "{0} 已新增至媒體庫", - "ItemRemovedWithName": "{0} 已從媒體庫移除", "LabelIpAddressValue": "IP 位址:{0}", "LabelRunningTimeValue": "運行時間:{0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin 伺服器已經更新", - "MessageApplicationUpdatedTo": "Jellyfin 伺服器已經更新至 {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 部分已經更新", - "MessageServerConfigurationUpdated": "伺服器設定已經更新", "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", @@ -59,22 +43,13 @@ "NotificationOptionVideoPlayback": "影片播放已開始", "NotificationOptionVideoPlaybackStopped": "影片播放已停止", "Photos": "相片", - "Playlists": "播放清單", - "Plugin": "擴充功能", "PluginInstalledWithName": "已安裝 {0}", "PluginUninstalledWithName": "已移除 {0}", "PluginUpdatedWithName": "已更新 {0}", - "ProviderValue": "提供者:{0}", "ScheduledTaskFailedWithName": "排程任務 {0} 執行失敗", - "ScheduledTaskStartedWithName": "排程任務 {0} 已開始", - "ServerNameNeedsToBeRestarted": "伺服器 {0} 需要重新啟動", "Shows": "節目", - "Songs": "歌曲", "StartupEmbyServerIsLoading": "Jellyfin 伺服器載入中,請稍後再試。", - "Sync": "同步", - "System": "系統", "TvShows": "電視節目", - "User": "使用者", "UserCreatedWithName": "已建立使用者 {0}", "UserDeletedWithName": "已刪除使用者 {0}", "UserDownloadingItemWithValues": "使用者 {0} 正在下載 {1}", @@ -82,13 +57,9 @@ "UserOfflineFromDevice": "使用者 {0} 已從 {1} 斷線", "UserOnlineFromDevice": "使用者 {0} 已從 {1} 連線", "UserPasswordChangedWithName": "使用者 {0} 的密碼已變更", - "UserPolicyUpdatedWithName": "使用者權限已更新為 {0}", "UserStartedPlayingItemWithValues": "{0} 正在 {2} 上播放 {1}", "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}", - "ValueHasBeenAddedToLibrary": "{0} 已新增至您的媒體庫", - "ValueSpecialEpisodeName": "特輯 - {0}", "VersionNumber": "版本 {0}", - "HeaderRecordingGroups": "錄製組", "Inherit": "繼承", "SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕", "TaskDownloadMissingSubtitlesDescription": "透過媒體資訊從網路上搜尋遺失的字幕。", diff --git a/Emby.Server.Implementations/Localization/Core/zu.json b/Emby.Server.Implementations/Localization/Core/zu.json index aa056d4498..669ea0372d 100644 --- a/Emby.Server.Implementations/Localization/Core/zu.json +++ b/Emby.Server.Implementations/Localization/Core/zu.json @@ -2,37 +2,24 @@ "TasksApplicationCategory": "Ukusetshenziswa", "TasksLibraryCategory": "Umtapo", "TasksMaintenanceCategory": "Ukunakekela", - "User": "Umsebenzisi", "Undefined": "Akuchaziwe", - "System": "Isistimu", - "Sync": "Vumelanisa", - "Songs": "Amaculo", "Shows": "Izinhlelo", - "Plugin": "Isijobelelo", - "Playlists": "Izinhla Zokudlalayo", "Photos": "Izithombe", "Music": "Umculo", "Movies": "Amamuvi", "Latest": "lwakamuva", "Inherit": "Ngefa", "Forced": "Kuphoqiwe", - "Application": "Ukusetshenziswa", "Genres": "Izinhlobo", "Folders": "Izikhwama", "Favorites": "Izintandokazi", "Default": "Okumisiwe", "Collections": "Amaqoqo", - "Channels": "Amashaneli", "Books": "Izincwadi", "Artists": "Abadlali", - "Albums": "Ama-albhamu", - "CameraImageUploadedFrom": "Kulandelayo lwesithonjana sekhamera selithunyelwe kusuka ku {0}", - "HeaderFavoriteArtists": "Abasethi Abathandekayo", "HeaderFavoriteEpisodes": "Izilimi Ezithandekayo", "HeaderFavoriteShows": "Izisho Ezithandekayo", "External": "Kwezifungo", "FailedLoginAttemptWithUserName": "Ukushayiswa kwesithombe sokungena okungekho {0}", - "HeaderContinueWatching": "Buyela Ukubona", - "HeaderFavoriteAlbums": "Izimpahla Ezithandwayo", - "HeaderAlbumArtists": "Abasethi wenkulumo" + "HeaderContinueWatching": "Buyela Ukubona" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 6fca5bc1ba..e7dd984ec4 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -26,6 +27,7 @@ namespace Emby.Server.Implementations.Localization private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings."; private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; + private const string CoreResourcePrefix = "Emby.Server.Implementations.Localization.Core."; private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; private static readonly string[] _unratedValues = ["n/a", "unrated", "not rated", "nr"]; @@ -34,13 +36,21 @@ namespace Emby.Server.Implementations.Localization private readonly Dictionary<string, Dictionary<string, ParentalRatingScore?>> _allParentalRatings = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, Dictionary<string, string>> _cultureOnlyDictionaries = new(StringComparer.OrdinalIgnoreCase); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase); private List<CultureDto> _cultures = []; + private static readonly (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) _localizationData = BuildLocalizationData(); + private static readonly IReadOnlyList<LocalizationOption> _localizationOptions = _localizationData.Options; + + // Maps BCP-47 hyphenated culture codes (set by ASP.NET Core's RequestLocalizationMiddleware + // and used as CurrentUICulture.Name) to Jellyfin's underscore-based resource file codes. + // Built reflexively from the resource file scan so both directions stay in sync. + private static readonly FrozenDictionary<string, string> _bcp47ToJellyfinMap = _localizationData.Bcp47ToJellyfinMap; + private FrozenDictionary<string, string> _iso6392BtoT = null!; /// <summary> @@ -54,6 +64,59 @@ namespace Emby.Server.Implementations.Localization { _configurationManager = configurationManager; _logger = logger; + + _configurationManager.ConfigurationUpdated += OnConfigurationUpdated; + } + + /// <summary> + /// Gets the supported UI cultures. + /// </summary> + /// <returns>A list of <see cref="CultureInfo"/> objects covering every embedded translation.</returns> + public static IList<CultureInfo> GetSupportedUICultures() + { + var cultures = new List<CultureInfo>(); + foreach (var option in _localizationOptions) + { + // Skip novelty codes (e.g. "pr" Pirate, "jbo" Lojban) that .NET cannot resolve. + if (TryGetCultureInfo(option.Value, out var cultureInfo)) + { + cultures.Add(cultureInfo); + } + } + + return cultures; + } + + /// <summary> + /// Resolves a Jellyfin resource culture code (which may use underscores, e.g. <c>es_419</c>) + /// to a <see cref="CultureInfo"/>. Returns <see langword="false"/> for codes .NET cannot resolve. + /// </summary> + private static bool TryGetCultureInfo(string cultureCode, [NotNullWhen(true)] out CultureInfo? cultureInfo) + { + try + { + // Resource files use underscores for some variants (e.g. es_419); + // CultureInfo only accepts hyphenated BCP-47 codes. + cultureInfo = CultureInfo.GetCultureInfo(cultureCode.Replace('_', '-')); + return true; + } + catch (CultureNotFoundException) + { + cultureInfo = null; + return false; + } + } + + private static void OnConfigurationUpdated(object? sender, EventArgs e) + { + if (sender is IServerConfigurationManager configManager) + { + var uiCulture = configManager.Configuration.UICulture; + if (!string.IsNullOrEmpty(uiCulture)) + { + CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(uiCulture); + } + } } /// <summary> @@ -255,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)]; @@ -293,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)) { @@ -301,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); } @@ -320,6 +404,14 @@ namespace Emby.Server.Implementations.Localization { return value; } + + if (ratingsDictionary is not null && rating.Length > countryCode.Length + && rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase) + && (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':') + && ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue)) + { + return normalizedValue; + } } else { @@ -345,38 +437,98 @@ namespace Emby.Server.Implementations.Localization } } - // Try splitting by : to handle "Germany: FSK-18" - if (rating.Contains(':', StringComparison.OrdinalIgnoreCase)) + // Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18" + if (TryGetRatingScoreBySeparator(rating, ':', out var result) + || TryGetRatingScoreBySeparator(rating, '-', out result)) { - var ratingLevelRightPart = rating.AsSpan().RightPart(':'); - if (ratingLevelRightPart.Length != 0) + return result; + } + + return null; + } + + private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result) + { + result = null; + + if (rating.IndexOf(separator, StringComparison.Ordinal) < 0) + { + return false; + } + + var ratingSpan = rating.AsSpan(); + var countryPart = ratingSpan.LeftPart(separator).Trim().ToString(); + var ratingPart = ratingSpan.RightPart(separator).Trim().ToString(); + if (ratingPart.Length == 0) + { + return false; + } + + string? resolvedCountryCode = null; + + if (_allParentalRatings.ContainsKey(countryPart)) + { + resolvedCountryCode = countryPart; + } + else + { + var culture = FindLanguageInfo(countryPart); + if (culture is not null) { - return GetRatingScore(ratingLevelRightPart.ToString()); + resolvedCountryCode = culture.TwoLetterISOLanguageName; } } - // Handle prefix country code to handle "DE-18" - if (rating.Contains('-', StringComparison.OrdinalIgnoreCase)) + if (resolvedCountryCode is not null + && _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings)) { - var ratingSpan = rating.AsSpan(); - - // Extract culture from country prefix - var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString()); + if (countryRatings.TryGetValue(ratingPart, out result)) + { + return true; + } - var ratingLevelRightPart = ratingSpan.RightPart('-'); - if (ratingLevelRightPart.Length != 0) + // If it's not a recognized rating string, fall back to using the number as the score + if (TryParseRatingAsScore(ratingPart, out var numericScore)) { - // Check rating system of culture - return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName); + result = new ParentalRatingScore(numericScore, null); + return true; } + + _logger.LogWarning( + "Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated", + rating, + resolvedCountryCode); + + return true; } - return null; + // Country not identified or no rating data available, try recursive lookup + result = GetRatingScore(ratingPart, resolvedCountryCode); + + 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) { + return GetLocalizedString(phrase, CultureInfo.CurrentUICulture.Name); + } + + /// <inheritdoc /> + public string GetServerLocalizedString(string phrase) + { return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture); } @@ -393,6 +545,12 @@ namespace Emby.Server.Implementations.Localization culture = DefaultCulture; } + // Normalize BCP-47 hyphenated codes to Jellyfin's underscore-based codes + if (_bcp47ToJellyfinMap.TryGetValue(culture, out var mapped)) + { + culture = mapped; + } + var dictionary = GetLocalizationDictionary(culture); if (dictionary.TryGetValue(phrase, out var value)) @@ -400,6 +558,15 @@ namespace Emby.Server.Implementations.Localization return value; } + if (!string.Equals(culture, DefaultCulture, StringComparison.OrdinalIgnoreCase)) + { + var fallback = GetLocalizationDictionary(DefaultCulture); + if (fallback.TryGetValue(phrase, out var fallbackValue)) + { + return fallbackValue; + } + } + return phrase; } @@ -407,26 +574,17 @@ namespace Emby.Server.Implementations.Localization { ArgumentException.ThrowIfNullOrEmpty(culture); - const string Prefix = "Core"; - - return _dictionaries.GetOrAdd( + return _cultureOnlyDictionaries.GetOrAdd( culture, - static (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(), - this); - } - - private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename) - { - ArgumentException.ThrowIfNullOrEmpty(culture); - - var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - var namespaceName = GetType().Namespace + "." + prefix; - - await CopyInto(dictionary, namespaceName + "." + baseFilename).ConfigureAwait(false); - await CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture)).ConfigureAwait(false); + static (key, localizationManager) => + { + var dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + var namespaceName = localizationManager.GetType().Namespace + ".Core"; + localizationManager.CopyInto(dictionary, namespaceName + "." + GetResourceFilename(key)).GetAwaiter().GetResult(); - return dictionary; + return dictionary; + }, + this); } private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath) @@ -448,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 { @@ -465,77 +627,55 @@ namespace Emby.Server.Implementations.Localization /// <inheritdoc /> public IEnumerable<LocalizationOption> GetLocalizationOptions() { - yield return new LocalizationOption("Afrikaans", "af"); - yield return new LocalizationOption("العربية", "ar"); - yield return new LocalizationOption("Беларуская", "be"); - yield return new LocalizationOption("Български", "bg-BG"); - yield return new LocalizationOption("বাংলা (বাংলাদেশ)", "bn"); - yield return new LocalizationOption("Català", "ca"); - yield return new LocalizationOption("Čeština", "cs"); - yield return new LocalizationOption("Cymraeg", "cy"); - yield return new LocalizationOption("Dansk", "da"); - yield return new LocalizationOption("Deutsch", "de"); - yield return new LocalizationOption("English (United Kingdom)", "en-GB"); - yield return new LocalizationOption("English", "en-US"); - yield return new LocalizationOption("Ελληνικά", "el"); - yield return new LocalizationOption("Esperanto", "eo"); - yield return new LocalizationOption("Español", "es"); - yield return new LocalizationOption("Español americano", "es_419"); - yield return new LocalizationOption("Español (Argentina)", "es-AR"); - yield return new LocalizationOption("Español (Dominicana)", "es_DO"); - yield return new LocalizationOption("Español (México)", "es-MX"); - yield return new LocalizationOption("Eesti", "et"); - yield return new LocalizationOption("Basque", "eu"); - yield return new LocalizationOption("فارسی", "fa"); - yield return new LocalizationOption("Suomi", "fi"); - yield return new LocalizationOption("Filipino", "fil"); - yield return new LocalizationOption("Français", "fr"); - yield return new LocalizationOption("Français (Canada)", "fr-CA"); - yield return new LocalizationOption("Galego", "gl"); - yield return new LocalizationOption("Schwiizerdütsch", "gsw"); - yield return new LocalizationOption("עִבְרִית", "he"); - yield return new LocalizationOption("हिन्दी", "hi"); - yield return new LocalizationOption("Hrvatski", "hr"); - yield return new LocalizationOption("Magyar", "hu"); - yield return new LocalizationOption("Bahasa Indonesia", "id"); - yield return new LocalizationOption("Íslenska", "is"); - yield return new LocalizationOption("Italiano", "it"); - yield return new LocalizationOption("日本語", "ja"); - yield return new LocalizationOption("Qazaqşa", "kk"); - yield return new LocalizationOption("한국어", "ko"); - yield return new LocalizationOption("Lietuvių", "lt"); - yield return new LocalizationOption("Latviešu", "lv"); - yield return new LocalizationOption("Македонски", "mk"); - yield return new LocalizationOption("മലയാളം", "ml"); - yield return new LocalizationOption("मराठी", "mr"); - yield return new LocalizationOption("Bahasa Melayu", "ms"); - yield return new LocalizationOption("Norsk bokmål", "nb"); - yield return new LocalizationOption("नेपाली", "ne"); - yield return new LocalizationOption("Nederlands", "nl"); - yield return new LocalizationOption("Norsk nynorsk", "nn"); - yield return new LocalizationOption("ਪੰਜਾਬੀ", "pa"); - yield return new LocalizationOption("Polski", "pl"); - yield return new LocalizationOption("Pirate", "pr"); - yield return new LocalizationOption("Português", "pt"); - yield return new LocalizationOption("Português (Brasil)", "pt-BR"); - yield return new LocalizationOption("Português (Portugal)", "pt-PT"); - yield return new LocalizationOption("Românește", "ro"); - yield return new LocalizationOption("Русский", "ru"); - yield return new LocalizationOption("Slovenčina", "sk"); - yield return new LocalizationOption("Slovenščina", "sl-SI"); - yield return new LocalizationOption("Shqip", "sq"); - yield return new LocalizationOption("Српски", "sr"); - yield return new LocalizationOption("Svenska", "sv"); - yield return new LocalizationOption("தமிழ்", "ta"); - yield return new LocalizationOption("తెలుగు", "te"); - yield return new LocalizationOption("ภาษาไทย", "th"); - yield return new LocalizationOption("Türkçe", "tr"); - yield return new LocalizationOption("Українська", "uk"); - yield return new LocalizationOption("اُردُو", "ur_PK"); - yield return new LocalizationOption("Tiếng Việt", "vi"); - yield return new LocalizationOption("汉语 (简体字)", "zh-CN"); - yield return new LocalizationOption("漢語 (繁體字)", "zh-TW"); - yield return new LocalizationOption("廣東話 (香港)", "zh-HK"); + return _localizationOptions; + } + + private static (IReadOnlyList<LocalizationOption> Options, FrozenDictionary<string, string> Bcp47ToJellyfinMap) BuildLocalizationData() + { + var options = new List<LocalizationOption>(); + var bcp47Map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + var prefix = CoreResourcePrefix; + + foreach (var resource in _assembly.GetManifestResourceNames()) + { + if (!resource.StartsWith(prefix, StringComparison.Ordinal) + || !resource.EndsWith(".json", StringComparison.Ordinal)) + { + continue; + } + + // Extract culture code from resource name: "...Core.de.json" -> "de", "...Core.pt-BR.json" -> "pt-BR" + var code = resource[prefix.Length..^5]; + + // Record the BCP-47 → Jellyfin mapping for any resource file using underscores. + if (code.Contains('_', StringComparison.Ordinal)) + { + bcp47Map[code.Replace('_', '-')] = code; + } + + // Skip the base language file — en-US is added explicitly below + if (code.Equals(DefaultCulture, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var displayName = GetDisplayName(code); + options.Add(new LocalizationOption(displayName, code)); + } + + // Ensure en-US is always present + options.Add(new LocalizationOption("English", DefaultCulture)); + + options.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + return (options, bcp47Map.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase)); + } + + private static string GetDisplayName(string cultureCode) + { + // Custom/novelty codes like "pr" (Pirate) — fall back to code itself + return TryGetCultureInfo(cultureCode, out var cultureInfo) + ? cultureInfo.NativeName + : cultureCode; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.json b/Emby.Server.Implementations/Localization/Ratings/ca.json index fa43a8f2b7..76550b64c3 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ca.json +++ b/Emby.Server.Implementations/Localization/Ratings/ca.json @@ -3,7 +3,7 @@ "supportsSubScores": true, "ratings": [ { - "ratingStrings": ["E", "G", "TV-Y", "TV-G"], + "ratingStrings": ["C", "E", "G", "TV-Y", "TV-G"], "ratingScore": { "score": 0, "subScore": 0 @@ -24,13 +24,20 @@ } }, { - "ratingStrings": ["PG", "TV-PG"], + "ratingStrings": ["C8"], "ratingScore": { - "score": 9, + "score": 8, "subScore": 0 } }, { + "ratingStrings": ["PG", "TV-PG"], + "ratingScore": { + "score": 8, + "subScore": 1 + } + }, + { "ratingStrings": ["14A"], "ratingScore": { "score": 14, @@ -38,7 +45,7 @@ } }, { - "ratingStrings": ["TV-14"], + "ratingStrings": ["14+", "TV-14"], "ratingScore": { "score": 14, "subScore": 1 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/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index aa5fbbdf73..5c9a94cd36 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -85,9 +85,17 @@ namespace Emby.Server.Implementations.Serialization /// <returns>System.Object.</returns> public object? DeserializeFromFile(Type type, string file) { - using (var stream = File.OpenRead(file)) + try { - return DeserializeFromStream(type, stream); + using (var stream = File.OpenRead(file)) + { + return DeserializeFromStream(type, stream); + } + } + catch (Exception ex) + { + ex.Data.Add("Filename", file); + throw; } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index e2ddf86c7a..828bdd6859 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -271,9 +271,9 @@ namespace Emby.Server.Implementations.Session user.LastActivityDate = activityDate; await _userManager.UpdateUserAsync(user).ConfigureAwait(false); } - catch (DbUpdateConcurrencyException e) + catch (DbUpdateConcurrencyException) { - _logger.LogDebug(e, "Error updating user's last activity date."); + _logger.LogDebug("Error updating user's last activity date due to concurrency conflict. This is an expected event."); } } } @@ -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); } } @@ -973,7 +1006,7 @@ namespace Emby.Server.Implementations.Session if (user.RememberAudioSelections) { - if (data.AudioStreamIndex != info.AudioStreamIndex) + if (info.AudioStreamIndex.HasValue && data.AudioStreamIndex != info.AudioStreamIndex) { data.AudioStreamIndex = info.AudioStreamIndex; changed = true; @@ -990,7 +1023,7 @@ namespace Emby.Server.Implementations.Session if (user.RememberSubtitleSelections) { - if (data.SubtitleStreamIndex != info.SubtitleStreamIndex) + if (info.SubtitleStreamIndex.HasValue && data.SubtitleStreamIndex != info.SubtitleStreamIndex) { data.SubtitleStreamIndex = info.SubtitleStreamIndex; changed = true; @@ -1021,15 +1054,22 @@ namespace Emby.Server.Implementations.Session ArgumentNullException.ThrowIfNull(info); + var session = GetSession(info.SessionId); + + session.StopAutomaticProgress(); + if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0) { + // Ensure live stream is cleaned up before throwing, to prevent tuner + // resource leaks when stalled clients report a negative PositionTicks. + if (!string.IsNullOrEmpty(info.LiveStreamId)) + { + await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false); + } + throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative."); } - var session = GetSession(info.SessionId); - - session.StopAutomaticProgress(); - var libraryItem = info.ItemId.IsEmpty() ? null : GetNowPlayingItem(session, info.ItemId); @@ -1088,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); } } @@ -1143,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; } @@ -1210,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, @@ -2049,7 +2097,7 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - var adminUserIds = _userManager.Users + var adminUserIds = _userManager.GetUsers() .Where(i => i.HasPermission(PermissionKind.IsAdministrator)) .Select(i => i.Id) .ToList(); 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/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index f97ab414ce..f19ca77818 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -87,6 +87,7 @@ public class ArtistsController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the artists.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetPersons")] public ActionResult<QueryResult<BaseItemDto>> GetArtists( [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, @@ -258,6 +259,7 @@ public class ArtistsController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the album artists.</returns> [HttpGet("AlbumArtists")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetPersons")] public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists( [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, @@ -399,6 +401,7 @@ public class ArtistsController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the artist.</returns> [HttpGet("{name}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetPerson")] public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); 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/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index ee912a9be8..b9958867e7 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// The dashboard controller. /// </summary> [Route("")] +[Tags("Plugin")] public class DashboardController : BaseJellyfinApiController { private readonly ILogger<DashboardController> _logger; 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/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 2f53784db1..cfc8be28ae 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -8,6 +8,8 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -24,16 +26,19 @@ public class FilterController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="FilterController"/> class. /// </summary> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public FilterController(ILibraryManager libraryManager, IUserManager userManager) + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public FilterController(ILibraryManager libraryManager, IUserManager userManager, ILocalizationManager localization) { _libraryManager = libraryManager; _userManager = userManager; + _localization = localization; } /// <summary> @@ -183,6 +188,36 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } + if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series)) + { + filters.AudioLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Audio) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture is null ? language : $"{culture.DisplayName} ({language})", + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + filters.SubtitleLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Subtitle) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture is null ? language : $"{culture.DisplayName} ({language})", + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + } + return filters; } } 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/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index f80d32d149..8cd79645a8 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -196,6 +196,7 @@ public class InstantMixController : BaseJellyfinApiController /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("MusicGenres/{name}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetInstantMixFromItem")] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName( [FromRoute, Required] string name, [FromQuery] Guid? userId, @@ -359,7 +360,7 @@ public class InstantMixController : BaseJellyfinApiController [HttpGet("MusicGenres/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("Use GetInstantMixFromMusicGenreByName")] + [Obsolete("Use GetInstantMixFromItem")] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( [FromQuery, Required] Guid id, [FromQuery] Guid? userId, diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 7effe61e49..5fc4ad88b6 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("Items")] [Authorize(Policy = Policies.RequiresElevation)] +[Tags("Library")] public class ItemRefreshController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 4faec060d8..e26264d292 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -242,6 +242,7 @@ public class ItemUpdateController : BaseJellyfinApiController item.ForcedSortName = request.ForcedSortName; item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle; + item.OriginalLanguage = string.IsNullOrWhiteSpace(request.OriginalLanguage) ? null : request.OriginalLanguage; item.CriticRating = request.CriticRating; @@ -278,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; @@ -287,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; @@ -296,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; @@ -313,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 53656186c8..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; @@ -31,7 +33,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] -[Tags("Item")] +[Tags("Library")] public class ItemsController : BaseJellyfinApiController { private readonly IUserManager _userManager; @@ -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> @@ -157,6 +163,8 @@ public class ItemsController : BaseJellyfinApiController /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param> + /// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitle language. This allows multiple, comma delimited values.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> @@ -247,6 +255,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -267,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 } @@ -276,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(); } @@ -298,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 @@ -326,188 +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(), + 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, + }; - query.ApplyFilters(filters); + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + { + query.CollapseBoxSetItems = false; + } - // Filter by Series Status - if (seriesStatus.Length != 0) + if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + { + if (query.HasSubtitles.Value) { - query.SeriesStatuses = seriesStatus; + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; } - - // Exclude Blocked Unrated Items - var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); - if (blockedUnratedItems is not null) + else { - query.BlockUnratedItems = blockedUnratedItems; + // if we search for items without subtitles, we don't need to check for subtitles of a specific language + query.SubtitleLanguages = []; } + } - // ExcludeLocationTypes - if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) - { - query.IsVirtualItem = false; - } + // 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; + } - if (locationTypes.Length > 0 && locationTypes.Length < 4) - { - query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); - } + query.ApplyFilters(filters); - // Min official rating - if (!string.IsNullOrWhiteSpace(minOfficialRating)) - { - query.MinParentalRating = _localization.GetRatingScore(minOfficialRating); - } + // Filter by Series Status + if (seriesStatus.Length != 0) + { + query.SeriesStatuses = seriesStatus; + } - // Max official rating - if (!string.IsNullOrWhiteSpace(maxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating); - } + // Exclude Blocked Unrated Items + var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); + if (blockedUnratedItems is not null) + { + query.BlockUnratedItems = blockedUnratedItems; + } - // Artists - if (artists.Length != 0) + // ExcludeLocationTypes + if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) + { + query.IsVirtualItem = false; + } + + if (locationTypes.Length > 0 && locationTypes.Length < 4) + { + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); + } + + // 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) + { + 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 { @@ -785,6 +893,8 @@ public class ItemsController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + [], + [], enableTotalRecordCount, enableImages).ConfigureAwait(false); @@ -853,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, @@ -871,6 +987,7 @@ public class ItemsController : BaseJellyfinApiController MediaTypes = mediaTypes, IsVirtualItem = false, CollapseBoxSetItems = false, + IncludeOwnedItems = true, EnableTotalRecordCount = enableTotalRecordCount, AncestorIds = ancestorIds, IncludeItemTypes = includeItemTypes, @@ -955,6 +1072,7 @@ public class ItemsController : BaseJellyfinApiController [HttpGet("UserItems/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> GetItemUserData( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -1010,6 +1128,7 @@ public class ItemsController : BaseJellyfinApiController [HttpPost("UserItems/{itemId}/UserData")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> UpdateItemUserData( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 69c17f2486..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; @@ -47,8 +48,10 @@ namespace Jellyfin.Api.Controllers; public class LibraryController : BaseJellyfinApiController { private readonly IProviderManager _providerManager; + 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; @@ -60,8 +63,10 @@ public class LibraryController : BaseJellyfinApiController /// Initializes a new instance of the <see cref="LibraryController"/> class. /// </summary> /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <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> @@ -70,8 +75,10 @@ public class LibraryController : BaseJellyfinApiController /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> public LibraryController( IProviderManager providerManager, + ISimilarItemsManager similarItemsManager, ILibraryManager libraryManager, IUserManager userManager, + ICollectionManager collectionManager, IDtoService dtoService, IActivityManager activityManager, ILocalizationManager localization, @@ -80,8 +87,10 @@ public class LibraryController : BaseJellyfinApiController IServerConfigurationManager serverConfigurationManager) { _providerManager = providerManager; + _similarItemsManager = similarItemsManager; _libraryManager = libraryManager; _userManager = userManager; + _collectionManager = collectionManager; _dtoService = dtoService; _activityManager = activityManager; _localization = localization; @@ -110,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> @@ -701,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> @@ -708,6 +794,7 @@ public class LibraryController : BaseJellyfinApiController /// <param name="userId">Optional. Filter by user id, and attach user data.</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. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <response code="200">Similar items returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] @@ -718,12 +805,13 @@ public class LibraryController : BaseJellyfinApiController [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetSimilarItems( [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + CancellationToken cancellationToken) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -746,57 +834,22 @@ public class LibraryController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields }; - var program = item as IHasProgramAttributes; - bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer; - bool? isSeries = item is Series || (program is not null && program.IsSeries); - - var includeItemTypes = new List<BaseItemKind>(); - if (isMovie.Value) - { - includeItemTypes.Add(BaseItemKind.Movie); - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - includeItemTypes.Add(BaseItemKind.Trailer); - includeItemTypes.Add(BaseItemKind.LiveTvProgram); - } - } - else if (isSeries.Value) - { - includeItemTypes.Add(BaseItemKind.Series); - } - else - { - // For non series and movie types these columns are typically null - // isSeries = null; - isMovie = null; - includeItemTypes.Add(item.GetBaseItemKind()); - } - - var query = new InternalItemsQuery(user) - { - Genres = item.Genres, - Tags = item.Tags, - Limit = limit, - IncludeItemTypes = includeItemTypes.ToArray(), - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie ?? true, - EnableGroupByMetadataKey = isMovie ?? false, - ExcludeItemIds = [itemId], - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] - }; - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } + // Get library options for provider configuration + var libraryOptions = _libraryManager.GetLibraryOptions(item); - var itemsResult = _libraryManager.GetItemList(query); + var itemsResult = await _similarItemsManager.GetSimilarItemsAsync( + item, + excludeArtistIds, + user, + dtoOptions, + limit, + libraryOptions, + cancellationToken).ConfigureAwait(false); var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); return new QueryResult<BaseItemDto>( - query.StartIndex, + 0, itemsResult.Count, returnList); } @@ -907,6 +960,17 @@ public class LibraryController : BaseJellyfinApiController .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(), + SimilarItemProviders = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider || p.Type == MetadataPluginType.SimilarityProvider)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = i.Type == MetadataPluginType.LocalSimilarityProvider + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(), + SupportedImageTypes = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) @@ -935,11 +999,11 @@ public class LibraryController : BaseJellyfinApiController try { await _activityManager.CreateAsync(new ActivityLog( - string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), + string.Format(CultureInfo.InvariantCulture, _localization.GetServerLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), "UserDownloadingContent", User.GetUserId()) { - ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetServerLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), ItemId = item.Id.ToString("N", CultureInfo.InvariantCulture) }).ConfigureAwait(false); } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 074cdb24e0..1c570daf21 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -744,10 +744,12 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="programId">Program id.</param> /// <param name="userId">Optional. Attach user data.</param> /// <response code="200">Program returned.</response> + /// <response code="404">Program not found.</response> /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns> [HttpGet("Programs/{programId}")] [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<BaseItemDto>> GetProgram( [FromRoute, Required] string programId, [FromQuery] Guid? userId) @@ -756,8 +758,14 @@ public class LiveTvController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var result = await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); + + if (result is null) + { + return NotFound(); + } - return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); + return Ok(result); } /// <summary> @@ -994,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/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index aa22bdf6af..4ff1eef413 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -72,6 +72,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpPost("UserPlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public async Task<ActionResult<UserItemDataDto?>> MarkPlayedItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, @@ -138,6 +139,7 @@ public class PlaystateController : BaseJellyfinApiController [HttpDelete("UserPlayedItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Tags("UserData")] public async Task<ActionResult<UserItemDataDto?>> MarkUnplayedItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) 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/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 9886d03dee..a144961d74 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -432,6 +432,7 @@ public class SessionController : BaseJellyfinApiController /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> [HttpGet("Auth/Providers")] [Authorize(Policy = Policies.RequiresElevation)] + [Tags("Authentication")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() { @@ -444,6 +445,7 @@ public class SessionController : BaseJellyfinApiController /// <response code="200">Password reset providers retrieved.</response> /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> [HttpGet("Auth/PasswordResetProviders")] + [Tags("Authentication")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.RequiresElevation)] public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 09f20558fe..fa6d9efe36 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -1,7 +1,6 @@ +using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Models.StartupDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Net; @@ -54,6 +53,7 @@ public class StartupController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> [HttpGet("Configuration")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use configuration endpoints")] public ActionResult<StartupConfigurationDto> GetStartupConfiguration() { return new StartupConfigurationDto @@ -73,6 +73,7 @@ public class StartupController : BaseJellyfinApiController /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Configuration")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Obsolete("Use configuration endpoints")] public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) { _config.Configuration.ServerName = startupConfiguration.ServerName ?? string.Empty; @@ -91,6 +92,7 @@ public class StartupController : BaseJellyfinApiController /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("RemoteAccess")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Obsolete("Use configuration endpoints")] public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) { NetworkConfiguration settings = _config.GetNetworkConfiguration(); @@ -107,11 +109,12 @@ public class StartupController : BaseJellyfinApiController [HttpGet("User")] [HttpGet("FirstUser", Name = "GetFirstUser_2")] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use authentication endpoints")] public async Task<StartupUserDto> GetFirstUser() { // TODO: Remove this method when startup wizard no longer requires an existing user. await _userManager.InitializeAsync().ConfigureAwait(false); - var user = _userManager.Users.First(); + var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization."); return new StartupUserDto { Name = user.Username @@ -131,22 +134,29 @@ public class StartupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) { - var user = _userManager.Users.First(); - if (string.IsNullOrWhiteSpace(startupUserDto.Password)) + var user = _userManager.GetFirstUser(); + if (user is null) { - return BadRequest("Password must not be empty"); + return NotFound(); } - if (startupUserDto.Name is not null) + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { - user.Username = startupUserDto.Name; + return BadRequest("Password must not be empty"); } 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)) + { + await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false); + } +#pragma warning restore CA1309 // Use ordinal string comparison + if (!string.IsNullOrEmpty(startupUserDto.Password)) { - await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); + await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false); } return NoContent(); diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index e2075c2b8d..6bddd85337 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -115,11 +115,14 @@ public class TrailersController : BaseJellyfinApiController /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param> + /// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <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, @@ -203,6 +206,8 @@ public class TrailersController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -294,6 +299,8 @@ public class TrailersController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + audioLanguages, + subtitleLanguages, enableTotalRecordCount, enableImages).ConfigureAwait(false); } 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/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 536b95dbb5..657bda4d15 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -208,6 +208,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> [HttpPost("AuthenticateByName")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) { var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); @@ -243,6 +244,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> [HttpPost("AuthenticateWithQuickConnect")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) { try @@ -288,7 +290,7 @@ public class UserController : BaseJellyfinApiController if (request.ResetPassword) { - await _userManager.ResetPassword(user).ConfigureAwait(false); + await _userManager.ResetPassword(user.Id).ConfigureAwait(false); } else { @@ -306,7 +308,7 @@ public class UserController : BaseJellyfinApiController } } - await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false); + await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false); var currentToken = User.GetToken(); @@ -369,7 +371,7 @@ public class UserController : BaseJellyfinApiController if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) { - await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false); } await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false); @@ -425,7 +427,7 @@ public class UserController : BaseJellyfinApiController // If removing admin access if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) + if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) { return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); } @@ -440,7 +442,7 @@ public class UserController : BaseJellyfinApiController // If disabling if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) + if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } @@ -522,7 +524,7 @@ public class UserController : BaseJellyfinApiController // no need to authenticate password for new user if (request.Password is not null) { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false); } var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString()); @@ -538,6 +540,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> [HttpPost("ForgotPassword")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) { var ip = HttpContext.GetNormalizedRemoteIP(); @@ -562,6 +565,7 @@ public class UserController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> [HttpPost("ForgotPassword/Pin")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("Authentication")] public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) { var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); @@ -597,7 +601,7 @@ public class UserController : BaseJellyfinApiController private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) { - var users = _userManager.Users; + var users = _userManager.GetUsers(); if (isDisabled.HasValue) { diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index b908f92be6..a718035528 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -31,6 +31,7 @@ namespace Jellyfin.Api.Controllers; /// </summary> [Route("")] [Authorize] +[Tags("Library")] public class UserLibraryController : BaseJellyfinApiController { private readonly IUserManager _userManager; @@ -212,6 +213,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpPost("UserFavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto> MarkFavoriteItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -259,6 +261,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpDelete("UserFavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto> UnmarkFavoriteItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -306,6 +309,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpDelete("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> DeleteUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) @@ -354,6 +358,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> [HttpPost("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] + [Tags("UserData")] public ActionResult<UserItemDataDto?> UpdateUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, @@ -424,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))); } @@ -482,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))); } @@ -572,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..09793553c2 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, BaseItemKind.MusicArtist]; + 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/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/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs index f76c4a9678..98da6c8f44 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -18,20 +17,25 @@ public class LibraryTypeOptionsDto /// <summary> /// Gets or sets the metadata fetchers. /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = []; /// <summary> /// Gets or sets the image fetchers. /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = []; + + /// <summary> + /// Gets or sets the similar item providers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> SimilarItemProviders { get; set; } = []; /// <summary> /// Gets or sets the supported image types. /// </summary> - public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); + public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = []; /// <summary> /// Gets or sets the default image options. /// </summary> - public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); + public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = []; } 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.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index f7660f35dd..c8983480c7 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -18,7 +18,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Data</PackageId> - <VersionPrefix>10.12.0</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> 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/Library/LyricDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs index 5f4864e953..cfd1cbe05b 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/LyricDownloadFailureLogger.cs @@ -37,7 +37,7 @@ public class LyricDownloadFailureLogger : IEventConsumer<LyricDownloadFailureEve await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LyricDownloadFailureFromForItem"), + _localizationManager.GetServerLocalizedString("LyricDownloadFailureFromForItem"), eventArgs.Provider, GetItemName(eventArgs.Item)), "LyricDownloadFailure", diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs index 8fe380e4f4..24146210c6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs @@ -37,7 +37,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Library await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("SubtitleDownloadFailureFromForItem"), + _localizationManager.GetServerLocalizedString("SubtitleDownloadFailureFromForItem"), eventArgs.Provider, GetItemName(eventArgs.Item)), "SubtitleDownloadFailure", diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs index 1a8931a6dc..df526977a2 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("FailedLoginAttemptWithUserName"), + _localizationManager.GetServerLocalizedString("FailedLoginAttemptWithUserName"), eventArgs.Username), "AuthenticationFailed", Guid.Empty) @@ -43,7 +43,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security LogSeverity = LogLevel.Error, ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.RemoteEndPoint), }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs index 584d559e44..fa9ce21170 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs @@ -33,14 +33,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("AuthenticationSucceededWithUserName"), + _localizationManager.GetServerLocalizedString("AuthenticationSucceededWithUserName"), eventArgs.User.Name), "AuthenticationSucceeded", eventArgs.User.Id) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.SessionInfo?.RemoteEndPoint), }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs index 73323acb37..8f71966b83 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs @@ -61,7 +61,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserStartedPlayingItemWithValues"), + _localizationManager.GetServerLocalizedString("UserStartedPlayingItemWithValues"), user.Username, GetItemName(eventArgs.MediaInfo), eventArgs.DeviceName), diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index b75567539c..a88904c727 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -69,15 +69,15 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserStoppedPlayingItemWithValues"), + _localizationManager.GetServerLocalizedString("UserStoppedPlayingItemWithValues"), user.Username, GetItemName(item), 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/Events/Consumers/Session/SessionEndedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs index b90708a2f2..74dfeebba6 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs @@ -38,7 +38,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserOfflineFromDevice"), + _localizationManager.GetServerLocalizedString("UserOfflineFromDevice"), eventArgs.Argument.UserName, eventArgs.Argument.DeviceName), "SessionEnded", @@ -46,7 +46,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.Argument.RemoteEndPoint), }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs index 139c2e2acb..4028522838 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs @@ -38,7 +38,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserOnlineFromDevice"), + _localizationManager.GetServerLocalizedString("UserOnlineFromDevice"), eventArgs.Argument.UserName, eventArgs.Argument.DeviceName), "SessionStarted", @@ -46,7 +46,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelIpAddressValue"), + _localizationManager.GetServerLocalizedString("LabelIpAddressValue"), eventArgs.Argument.RemoteEndPoint) }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs index da82a3b30f..1e3dc7c92e 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs @@ -47,7 +47,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System var time = result.EndTimeUtc - result.StartTimeUtc; var runningTime = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("LabelRunningTimeValue"), + _localizationManager.GetServerLocalizedString("LabelRunningTimeValue"), ToUserFriendlyString(time)); if (result.Status == TaskCompletionStatus.Failed) @@ -65,7 +65,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.System } await _activityManager.CreateAsync(new ActivityLog( - string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("ScheduledTaskFailedWithName"), task.Name), + string.Format(CultureInfo.InvariantCulture, _localizationManager.GetServerLocalizedString("ScheduledTaskFailedWithName"), task.Name), NotificationType.TaskFailed.ToString(), Guid.Empty) { diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs index 632f30c7ad..9fb007aca7 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs @@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("NameInstallFailed"), + _localizationManager.GetServerLocalizedString("NameInstallFailed"), eventArgs.InstallationInfo.Name), NotificationType.InstallationFailed.ToString(), Guid.Empty) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("VersionNumber"), + _localizationManager.GetServerLocalizedString("VersionNumber"), eventArgs.InstallationInfo.Version), Overview = eventArgs.Exception.Message }).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs index 4b49b714cf..2aa738c153 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs @@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("PluginInstalledWithName"), + _localizationManager.GetServerLocalizedString("PluginInstalledWithName"), eventArgs.Argument.Name), NotificationType.PluginInstalled.ToString(), Guid.Empty) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("VersionNumber"), + _localizationManager.GetServerLocalizedString("VersionNumber"), eventArgs.Argument.Version) }).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs index 2d24de7fc6..f7e651173d 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("PluginUninstalledWithName"), + _localizationManager.GetServerLocalizedString("PluginUninstalledWithName"), eventArgs.Argument.Name), NotificationType.PluginUninstalled.ToString(), Guid.Empty)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs index e892d3dd9a..bca9662839 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs @@ -35,14 +35,14 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Updates await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("PluginUpdatedWithName"), + _localizationManager.GetServerLocalizedString("PluginUpdatedWithName"), eventArgs.Argument.Name), NotificationType.PluginUpdateInstalled.ToString(), Guid.Empty) { ShortOverview = string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("VersionNumber"), + _localizationManager.GetServerLocalizedString("VersionNumber"), eventArgs.Argument.Version), Overview = eventArgs.Argument.Changelog }).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs index 4f063f6a1b..cf5c81b981 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserCreatedWithName"), + _localizationManager.GetServerLocalizedString("UserCreatedWithName"), eventArgs.Argument.Username), "UserCreated", eventArgs.Argument.Id)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs index ba4a072e84..720480c28f 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs @@ -34,7 +34,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserDeletedWithName"), + _localizationManager.GetServerLocalizedString("UserDeletedWithName"), eventArgs.Argument.Username), "UserDeleted", Guid.Empty)) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs index bbc00567d1..efaf19397f 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserLockedOutWithName"), + _localizationManager.GetServerLocalizedString("UserLockedOutWithName"), eventArgs.Argument.Username), NotificationType.UserLockedOut.ToString(), eventArgs.Argument.Id) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs index 7219704ec6..cc9efa7061 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Users await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserPasswordChangedWithName"), + _localizationManager.GetServerLocalizedString("UserPasswordChangedWithName"), eventArgs.Argument.Username), "UserPasswordChanged", eventArgs.Argument.Id)) 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 67a233c41d..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 @@ -68,6 +68,7 @@ internal static class BaseItemMapper dto.CriticRating = entity.CriticRating; dto.PresentationUniqueKey = entity.PresentationUniqueKey; dto.OriginalTitle = entity.OriginalTitle; + dto.OriginalLanguage = entity.OriginalLanguage; dto.Album = entity.Album; dto.LUFS = entity.LUFS; dto.NormalizationGain = entity.NormalizationGain; @@ -243,6 +244,7 @@ internal static class BaseItemMapper entity.CriticRating = dto.CriticRating; entity.PresentationUniqueKey = dto.PresentationUniqueKey; entity.OriginalTitle = dto.OriginalTitle; + entity.OriginalLanguage = dto.OriginalLanguage; entity.Album = dto.Album; entity.LUFS = dto.LUFS; entity.NormalizationGain = dto.NormalizationGain; diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 380c6e582c..c5b5fbf6d8 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -170,92 +169,50 @@ public sealed partial class BaseItemRepository ExcludeItemIds = filter.ExcludeItemIds }; - // Build the master query and collapse rows that share a PresentationUniqueKey - // (e.g. alternate versions) by picking the lowest Id per group. + // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking + // the lowest Id per group. For MusicArtist, prefer the entity from a library the user + // can actually access,since the same artist can have a folder in multiple libraries. + // Keep as an IQueryable sub-select so paging is applied AFTER + // ApplyOrder runs the caller's actual sort. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - - var orderedMasterQuery = ApplyOrder(masterQuery, filter, context) - .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) { - result.TotalRecordCount = orderedMasterQuery.Count(); + result.TotalRecordCount = representativeIds.Count(); } + var query = ApplyNavigations( + context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)), + filter); + + query = ApplyOrder(query, filter, context); + if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) { - orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value); + query = query.Skip(filter.StartIndex.Value); } if (filter.Limit.HasValue) { - orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value); + query = query.Take(filter.Limit.Value); } - var masterIds = orderedMasterQuery.ToList(); - - var query = ApplyNavigations( - context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)), - filter); - - query = ApplyOrder(query, filter, context); - + result.StartIndex = filter.StartIndex ?? 0; if (filter.IncludeItemTypes.Length > 0) { - var typeSubQuery = new InternalItemsQuery(filter.User) - { - ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, - MediaTypes = filter.MediaTypes, - AncestorIds = filter.AncestorIds, - ExcludeItemIds = filter.ExcludeItemIds, - ItemIds = filter.ItemIds, - TopParentIds = filter.TopParentIds, - ParentId = filter.ParentId, - IsPlayed = filter.IsPlayed - }; - - var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); - - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; - var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var itemIds = itemCountQuery.Select(e => e.Id); - - // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) - // Instead, start from ItemValueMaps and join with BaseItems - var countsByCleanName = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Where(ivm => itemIds.Contains(ivm.ItemId)) - .Join( - context.BaseItems, - ivm => ivm.ItemId, - e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) - .GroupBy(x => new { x.CleanName, x.Type }) - .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) - .GroupBy(x => x.CleanName) - .ToDictionary( - g => g.Key, - g => new ItemCounts - { - SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), - EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), - MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), - AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), - ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), - SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), - TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), - }); - - result.StartIndex = filter.StartIndex ?? 0; + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes); result.Items = [ .. query @@ -273,7 +230,6 @@ public sealed partial class BaseItemRepository } else { - result.StartIndex = filter.StartIndex ?? 0; result.Items = [ .. query @@ -287,4 +243,61 @@ public sealed partial class BaseItemRepository return result; } + + private Dictionary<string, ItemCounts> BuildItemCountsByCleanName( + Database.Implementations.JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList<ItemValueType> itemValueTypes) + { + var typeSubQuery = new InternalItemsQuery(filter.User) + { + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ExcludeItemIds = filter.ExcludeItemIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsPlayed = filter.IsPlayed + }; + + var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) + .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; + var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + var itemIds = itemCountQuery.Select(e => e.Id); + + // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) + // Instead, start from ItemValueMaps and join with BaseItems + return context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .Where(ivm => itemIds.Contains(ivm.ItemId)) + .Join( + context.BaseItems, + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) + .GroupBy(x => new { x.CleanName, x.Type }) + .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) + .GroupBy(x => x.CleanName) + .ToDictionary( + g => g.Key, + g => new ItemCounts + { + SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count), + EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count), + MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count), + AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count), + ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count), + SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count), + TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count), + }); + } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index d6ddf8f5c8..decd45ae2c 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) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index d516bc0d13..dc16c3b1b3 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; @@ -125,45 +124,53 @@ public sealed partial class BaseItemRepository return GetLatestTvShowItems(context, baseQuery, filter, limit); } - // Find the top N group keys ordered by most recent DateCreated. - // Movies group by PresentationUniqueKey (alternate versions like 4K/1080p share a key). - // Music groups by Album. - Expression<Func<BaseItemEntity, bool>> groupKeyFilter; - Expression<Func<BaseItemEntity, string?>> groupKeySelector; - if (collectionType is CollectionType.movies) { - groupKeyFilter = e => e.PresentationUniqueKey != null; - groupKeySelector = e => e.PresentationUniqueKey; - } - else - { - groupKeyFilter = e => e.Album != null; - groupKeySelector = e => e.Album; + // Group by PresentationUniqueKey, pick the newest item per group. + var topGroupItems = baseQuery + .Where(e => e.PresentationUniqueKey != null) + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => new + { + MaxDate = g.Max(e => e.DateCreated), + FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First() + }) + .OrderByDescending(g => g.MaxDate); + + var firstIdsQuery = filter.Limit.HasValue + ? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId) + : topGroupItems.Select(g => g.FirstId); + + return LoadLatestByIds(context, firstIdsQuery, filter); } - // Group by GroupKey, pick the latest item per group (correlated subquery: ORDER BY DateCreated DESC, Id DESC LIMIT 1), - // order groups by group max date, take the top N — all in a single SQL statement. - // ThenByDescending(Id) is the tiebreaker for deterministic ordering when items share a DateCreated. - var topGroupItems = baseQuery - .Where(groupKeyFilter) - .GroupBy(groupKeySelector) - .Select(g => new - { - MaxDate = g.Max(e => e.DateCreated), - FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First() - }) - .OrderByDescending(g => g.MaxDate); + // Albums whose Id is the parent of any track matching the user's filter. + var albumIdsWithMatchingTrack = context.AncestorIds + .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId); - var firstIdsQuery = filter.Limit.HasValue - ? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId) - : topGroupItems.Select(g => g.FirstId); + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!; + var topAlbumsQuery = context.BaseItems.AsNoTracking() + .Where(album => album.Type == musicAlbumTypeName) + .Where(album => albumIdsWithMatchingTrack.Contains(album.Id)) + .OrderByDescending(album => album.DateCreated) + .ThenByDescending(album => album.Id); - var firstIds = firstIdsQuery.ToList(); + var albumIdsQuery = filter.Limit.HasValue + ? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id) + : topAlbumsQuery.Select(a => a.Id); - // Single bound JSON / array parameter via WhereOneOrMany — keeps SQL small regardless of N. - var itemsQuery = context.BaseItems.AsNoTracking().WhereOneOrMany(firstIds, e => e.Id); - itemsQuery = ApplyNavigations(itemsQuery, filter); + return LoadLatestByIds(context, albumIdsQuery, filter); + } + + // Keeping idsQuery deferred lets EF emit `WHERE Id IN (<subquery>)`. + private IReadOnlyList<BaseItemDto> LoadLatestByIds( + JellyfinDbContext context, + IQueryable<Guid> idsQuery, + InternalItemsQuery filter) + { + var itemsQuery = ApplyNavigations( + context.BaseItems.AsNoTracking().Where(e => idsQuery.Contains(e.Id)), + filter); return itemsQuery .OrderByDescending(e => e.DateCreated) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 0abe981af8..52cebccc37 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -390,7 +390,8 @@ public sealed partial class BaseItemRepository { if (filter.UseRawName == true) { - baseQuery = baseQuery.Where(e => e.Name == filter.Name); + var nameLower = filter.Name.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower); } else { @@ -512,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() @@ -534,26 +539,44 @@ 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. + baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems + .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))); } 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)); } } @@ -585,8 +608,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) @@ -741,10 +763,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) @@ -823,6 +848,26 @@ public sealed partial class BaseItemRepository } } + if (filter.SubtitleLanguages.Count > 0) + { + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages)); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle + && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + + if (filter.AudioLanguages.Count > 0) + { + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages)); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio + && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + || (e.IsFolder && foldersWithAudio.Contains(e.Id))); + } + if (filter.HasChapterImages.HasValue) { var hasChapterImages = filter.HasChapterImages.Value; @@ -932,23 +977,27 @@ 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) + 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) + { + var includeAny = filter.HasAnyProviderIds + .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) + .ToArray(); + if (includeAny.Length > 0) { - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f))); } } @@ -995,6 +1044,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 @@ -1056,8 +1114,12 @@ public sealed partial class BaseItemRepository if (filter.VideoTypes.Length > 0) { + // Dvds and Blu-rays can either be stored in a folder structure or as an iso file + // => to find all matches we need to check both: VideoType and IsoType + // alternatively, we could provide specific IsoType filters var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); - Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + var isoTypeBs = filter.VideoTypes.Select(vt => $"\"IsoType\":\"{vt}\"").ToArray(); + Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)) || isoTypeBs.Any(f => e.Data!.Contains(f)); baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); } 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/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs index 98700f3224..f7d76517e1 100644 --- a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs +++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs @@ -55,6 +55,7 @@ public class ChapterRepository : IChapterRepository { using var context = _dbProvider.CreateDbContext(); return context.Chapters.AsNoTracking().Where(e => e.ItemId.Equals(baseItemId)) + .OrderBy(e => e.StartPositionTicks) .Select(e => new { chapter = e, @@ -69,18 +70,16 @@ public class ChapterRepository : IChapterRepository public void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters) { using var context = _dbProvider.CreateDbContext(); - using (var transaction = context.Database.BeginTransaction()) + using var transaction = context.Database.BeginTransaction(); + context.Chapters.Where(e => e.ItemId.Equals(itemId)).ExecuteDelete(); + for (var i = 0; i < chapters.Count; i++) { - context.Chapters.Where(e => e.ItemId.Equals(itemId)).ExecuteDelete(); - for (var i = 0; i < chapters.Count; i++) - { - var chapter = chapters[i]; - context.Chapters.Add(Map(chapter, i, itemId)); - } - - context.SaveChanges(); - transaction.Commit(); + var chapter = chapters[i]; + context.Chapters.Add(Map(chapter, i, itemId)); } + + context.SaveChanges(); + transaction.Commit(); } /// <inheritdoc /> 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 415510b2f4..5e5ce320a5 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -1,4 +1,6 @@ #pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1311 // Specify a culture or use an invariant version using System; using System.Collections.Generic; @@ -62,17 +64,19 @@ public class LinkedChildrenService : ILinkedChildrenService { using var dbContext = _dbProvider.CreateDbContext(); + var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray(); var artists = dbContext.BaseItems .AsNoTracking() .Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) - .Where(e => artistNames.Contains(e.Name)) + .Where(e => lowerNames.Contains(e.Name!.ToLower())) .ToArray(); var lookup = artists - .GroupBy(e => e.Name!) + .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); + g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(), + StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, MusicArtist[]>(artistNames.Count); foreach (var name in artistNames) @@ -87,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/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 64874ccad7..7fa33c8639 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -55,6 +55,17 @@ public class MediaStreamRepository : IMediaStreamRepository return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray(); } + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + using var context = _dbProvider.CreateDbContext(); + return context.MediaStreamInfos + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string? GetPathToSave(string? path) { if (path is null) @@ -123,6 +134,7 @@ public class MediaStreamRepository : IMediaStreamRepository dto.IsDefault = entity.IsDefault; dto.IsForced = entity.IsForced; dto.IsExternal = entity.IsExternal; + dto.IsOriginal = entity.IsOriginal; dto.Height = entity.Height; dto.Width = entity.Width; dto.AverageFrameRate = entity.AverageFrameRate; @@ -164,6 +176,11 @@ public class MediaStreamRepository : IMediaStreamRepository dto.LocalizedLanguage = culture?.DisplayName; } + if (dto.Type is MediaStreamType.Audio) + { + dto.LocalizedOriginal = _localization.GetLocalizedString("Original"); + } + if (dto.Type is MediaStreamType.Subtitle) { dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); @@ -198,6 +215,7 @@ public class MediaStreamRepository : IMediaStreamRepository IsDefault = dto.IsDefault, IsForced = dto.IsForced, IsExternal = dto.IsExternal, + IsOriginal = dto.IsOriginal, Height = dto.Height, Width = dto.Width, AverageFrameRate = dto.AverageFrameRate, diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs index d78e246691..725b4cfaac 100644 --- a/Jellyfin.Server.Implementations/Item/NextUpService.cs +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -127,15 +127,21 @@ public class NextUpService : INextUpService .AsNoTracking() .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) - .Where(e => e.ParentIndexNumber != 0) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(e => e.ParentIndexNumber != 0); lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter); - // Use lightweight projection + client-side grouping instead of - // SelectMany+GroupBy+OrderByDescending+FirstOrDefault (correlated subquery). + // Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation. + // SelectMany on UserData with a correlated Where would translate to APPLY, + // which SQLite does not support. var playedWithDates = lastWatchedByDateBase - .SelectMany(e => e.UserData!.Where(ud => ud.UserId == userId && ud.Played) - .Select(ud => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })) + .Join( + context.UserData + .AsNoTracking() + .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId)) + .Where(ud => ud.Played), + e => new { UserId = userId, ItemId = e.Id }, + ud => new { ud.UserId, ud.ItemId }, + (e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }) .ToList(); foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey)) diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index ada86c8b87..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, @@ -48,9 +55,9 @@ public static class OrderMapper (ItemSortBy.SeriesSortName, _) => e => e.SeriesName, (ItemSortBy.Album, _) => e => e.Album, (ItemSortBy.DateCreated, _) => e => e.DateCreated, - (ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)), + (ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null), (ItemSortBy.StartDate, _) => e => e.StartDate, - (ItemSortBy.Name, _) => e => e.CleanName, + (ItemSortBy.Name, _) => e => e.SortName, (ItemSortBy.CommunityRating, _) => e => e.CommunityRating, (ItemSortBy.ProductionYear, _) => e => e.ProductionYear, (ItemSortBy.CriticRating, _) => e => e.CriticRating, diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index cfc4eb2162..eb87b525fe 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -44,7 +44,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I } else { - dbQuery = dbQuery.OrderBy(e => e.Name); + // The Peoples table has one row per (Name, PersonType), so the same person can + // appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per + // name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the + // grouping key so case-only duplicates collapse together. + var representativeIds = dbQuery + .GroupBy(e => e.Name.ToLower()) + .Select(g => g.Min(e => e.Id)); + dbQuery = context.Peoples.AsNoTracking() + .Where(p => representativeIds.Contains(p.Id)) + .OrderBy(e => e.Name); } var count = dbQuery.Count(); @@ -94,24 +103,23 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I person.Role = person.Role?.Trim() ?? string.Empty; } - // multiple metadata providers can provide the _same_ person - people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray(); - var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray(); + // multiple metadata providers can provide the _same_ person; dedupe case-insensitively. + people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); + var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); var existingPersons = context.Peoples.Select(e => new - { - item = e, - SelectionKey = e.Name + "-" + e.PersonType - }) + { + item = e, + SelectionKey = e.Name.ToLower() + "-" + e.PersonType + }) .Where(p => personKeys.Contains(p.SelectionKey)) .Select(f => f.item) .ToArray(); var toAdd = people - .Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist) - .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString())) + .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString())) .Select(Map); context.Peoples.AddRange(toAdd); context.SaveChanges(); @@ -124,13 +132,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I foreach (var person in people) { - if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist) - { - continue; - } - - var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString()); - var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); + var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString()); + var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); if (existingMap is null) { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() @@ -162,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(); @@ -231,12 +270,12 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (queryExcludePersonTypes.Count > 0) { - query = query.Where(e => !queryPersonTypes.Contains(e.PersonType)); + query = query.Where(e => !queryExcludePersonTypes.Contains(e.PersonType)); } 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/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index c514735688..be98f93dab 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -54,7 +54,7 @@ public class MediaSegmentManager : IMediaSegmentManager public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken) { var providers = _segmentProviders - .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase)) .OrderBy(i => { var index = libraryOptions.MediaSegmentProviderOrder.IndexOf(i.Name); @@ -81,6 +81,8 @@ public class MediaSegmentManager : IMediaSegmentManager foreach (var provider in providers) { + cancellationToken.ThrowIfCancellationRequested(); + if (!await provider.Supports(baseItem).ConfigureAwait(false)) { _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); @@ -146,6 +148,15 @@ public class MediaSegmentManager : IMediaSegmentManager await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug(ex, "Provider {ProviderName} aborted segment extraction for {MediaPath} due to shutdown", provider.Name, baseItem.Path); + break; + } catch (Exception ex) { _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); @@ -224,7 +235,7 @@ public class MediaSegmentManager : IMediaSegmentManager if (filterByProvider) { var providerIds = _segmentProviders - .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase)) .Select(f => GetProviderId(f.Name)) .ToArray(); if (providerIds.Length == 0) 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 63319831e1..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 @@ -198,13 +356,13 @@ public class TrickplayManager : ITrickplayManager // Cleanup old trickplay files if (Directory.Exists(trickplayDirectory)) { - var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList(); + var existingFolders = Directory.GetDirectories(trickplayDirectory); var trickplayInfos = await dbContext.TrickplayInfos .AsNoTracking() .Where(i => i.ItemId.Equals(video.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); - var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)).ToList(); + var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)); var foldersToRemove = existingFolders.Except(expectedFolders); foreach (var folder in foldersToRemove) { @@ -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/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 49a9fda943..7371545914 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users var resetUser = userManager.GetUserByName(spr.UserName) ?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found"); - await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false); + await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false); usersReset.Add(resetUser.Username); File.Delete(resetFile); } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 7292e9c7a9..583f29f94f 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,12 +1,13 @@ -#pragma warning disable CA1307 +#pragma warning disable RS0030 // Do not use banned APIs using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; @@ -35,7 +36,7 @@ namespace Jellyfin.Server.Implementations.Users /// <summary> /// Manages the creation and retrieval of <see cref="User"/> instances. /// </summary> - public partial class UserManager : IUserManager + public partial class UserManager : IUserManager, IDisposable { private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IEventManager _eventManager; @@ -50,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IDictionary<Guid, User> _users; + private readonly LockHelper _userLock = new(); /// <summary> /// Initializes a new instance of the <see cref="UserManager"/> class. @@ -89,29 +90,28 @@ namespace Jellyfin.Server.Implementations.Users _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First(); - - _users = new ConcurrentDictionary<Guid, User>(); - using var dbContext = _dbProvider.CreateDbContext(); - foreach (var user in dbContext.Users - .AsSingleQuery() - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsEnumerable()) - { - _users.Add(user.Id, user); - } } /// <inheritdoc/> public event EventHandler<GenericEventArgs<User>>? OnUserUpdated; /// <inheritdoc/> - public IEnumerable<User> Users => _users.Values; + public IEnumerable<User> GetUsers() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .ToArray(); + } /// <inheritdoc/> - public IEnumerable<Guid> UsersIds => _users.Keys; + public IEnumerable<Guid> GetUsersIds() + { + using var dbContext = _dbProvider.CreateDbContext(); + return dbContext.Users + .AsNoTracking() + .Select(user => user.Id) + .ToArray(); + } // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness @@ -127,8 +127,27 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Guid can't be empty", nameof(id)); } - _users.TryGetValue(id, out var user); - return user; + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .FirstOrDefault(user => user.Id == id); + } + + private static IQueryable<User> UserQuery(JellyfinDbContext dbContext) + { + return dbContext.Users + .AsSingleQuery() + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage) + .AsNoTracking(); + } + + /// <inheritdoc/> + public User? GetFirstUser() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext).FirstOrDefault(); } /// <inheritdoc/> @@ -139,42 +158,50 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Invalid username", nameof(name)); } - return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); + using var dbContext = _dbProvider.CreateDbContext(); +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + return UserQuery(dbContext) + .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } /// <inheritdoc/> - public async Task RenameUser(User user, string newName) + public async Task RenameUser(Guid userId, string oldName, string newName) { - ArgumentNullException.ThrowIfNull(user); - ThrowIfInvalidUsername(newName); - if (user.Username.Equals(newName, StringComparison.Ordinal)) + if (oldName.Equals(newName, StringComparison.Ordinal)) { throw new ArgumentException("The new and old names must be different."); } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + User user = null!; // user is never actually null where its used afterwards so we can just ignore. + using (await _userLock.LockAsync(userId).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.Equals(user.Id)) - .ConfigureAwait(false)) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "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 disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + if (await dbContext.Users + .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + newName)); + } #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - user.Username = newName; - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + user = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + user.Username = newName; + user.NormalizedUsername = newName.ToUpperInvariant(); + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + } } var eventArgs = new UserUpdatedEventArgs(user); @@ -185,10 +212,60 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdateUserAsync(User user) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - await UpdateUserInternalAsync(dbContext, 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); + } } } @@ -218,23 +295,26 @@ namespace Jellyfin.Server.Implementations.Users { ThrowIfInvalidUsername(name); - if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) - { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "A user with the name '{0}' already exists.", - name)); - } - User newUser; var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + if (await dbContext.Users + .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + name)); + } +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); dbContext.Users.Add(newUser); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _users.Add(newUser.Id, newUser); } await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); @@ -245,62 +325,82 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task DeleteUserAsync(Guid userId) { - if (!_users.TryGetValue(userId, out var user)) + User? user; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - throw new ResourceNotFoundException(nameof(userId)); - } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + user = await dbContext.Users + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false); + if (user is null) + { + throw new ResourceNotFoundException(nameof(userId)); + } - if (_users.Count == 1) - { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one user in the system.", - user.Username)); - } + var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false); + if (userCount == 1) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one user in the system.", + user.Username)); + } - if (user.HasPermission(PermissionKind.IsAdministrator) - && Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", - user.Username), - nameof(userId)); - } + if (user.HasPermission(PermissionKind.IsAdministrator) + && await dbContext.Users + .CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) + .ConfigureAwait(false) == 1) + { + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", + user.Username), + nameof(userId)); + } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - dbContext.Users.Attach(user); - dbContext.Users.Remove(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } - _users.Remove(userId); - await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } /// <inheritdoc/> - public Task ResetPassword(User user) + public Task ResetPassword(Guid userId) { - return ChangePassword(user, string.Empty); + return ChangePassword(userId, string.Empty); } /// <inheritdoc/> - public async Task ChangePassword(User user, string newPassword) + public async Task ChangePassword(Guid userId, string newPassword) { - ArgumentNullException.ThrowIfNull(user); - if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) + User dbUser = null!; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); - } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) + { + throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); + } - await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false); - await UpdateUserAsync(user).ConfigureAwait(false); + await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } - await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false); + await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false); } /// <inheritdoc/> @@ -400,102 +500,157 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentNullException(nameof(username)); } - var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); - var authResult = await AuthenticateLocalUser(username, password, user) - .ConfigureAwait(false); - var authenticationProvider = authResult.AuthenticationProvider; - var success = authResult.Success; - - if (user is null) + bool success; + var user = GetUserByName(username); + using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) { - string updatedUsername = authResult.Username; + using var dbContext = _dbProvider.CreateDbContext(); - if (success - && authenticationProvider is not null - && authenticationProvider is not DefaultAuthenticationProvider) + // 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) { - // Trust the username returned by the authentication provider - username = updatedUsername; + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; + } - // Search the database for the user again - // the authentication provider might have created it - user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); + var authResult = await AuthenticateLocalUser(username, password, user) + .ConfigureAwait(false); + var authenticationProvider = authResult.AuthenticationProvider; + success = authResult.Success; - if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) + 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; + + if (success + && authenticationProvider is not null + && authenticationProvider is not DefaultAuthenticationProvider) { - await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + // Trust the username returned by the authentication provider + username = updatedUsername; + + // Search the database for the user again + // the authentication provider might have created it +#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 + } } } - } - if (success && user is not null && authenticationProvider is not null) - { - var providerId = authenticationProvider.GetType().FullName; - - if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + if (success && user is not null && authenticationProvider is not null) { - user.AuthenticationProviderId = providerId; - await UpdateUserAsync(user).ConfigureAwait(false); + var providerId = authenticationProvider.GetType().FullName; + + if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + { + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId)) + .ConfigureAwait(false); + } } - } - if (user is null) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - username, - remoteEndPoint); - throw new AuthenticationException("Invalid username or password entered."); - } + if (user is null) + { + _logger.LogInformation( + "Authentication request for {UserName} has been denied (IP: {IP}).", + username, + remoteEndPoint); + throw new AuthenticationException("Invalid username or password entered."); + } - if (user.HasPermission(PermissionKind.IsDisabled)) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException( - $"The {user.Username} account is currently disabled. Please consult with your administrator."); - } + if (user.HasPermission(PermissionKind.IsDisabled)) + { + _logger.LogInformation( + "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException( + $"The {user.Username} account is currently disabled. Please consult with your administrator."); + } - if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && - !_networkManager.IsInLocalNetwork(remoteEndPoint)) - { - _logger.LogInformation( - "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("Forbidden."); - } + if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && + !_networkManager.IsInLocalNetwork(remoteEndPoint)) + { + _logger.LogInformation( + "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("Forbidden."); + } - if (!user.IsParentalScheduleAllowed()) - { - _logger.LogInformation( - "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("User is not allowed access at this time."); - } + if (!user.IsParentalScheduleAllowed()) + { + _logger.LogInformation( + "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("User is not allowed access at this time."); + } - // Update LastActivityDate and LastLoginDate, then save - if (success) - { - if (isUserSession) + // Update LastActivityDate and LastLoginDate, then save + if (success) { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + if (isUserSession) + { + 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); + } + + 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 + { + 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); + } - user.InvalidLoginAttemptCount = 0; - await UpdateUserAsync(user).ConfigureAwait(false); - _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); - } - else - { - await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - user.Username, - remoteEndPoint); + 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, + remoteEndPoint); + } } return success ? user : null; @@ -539,22 +694,22 @@ namespace Jellyfin.Server.Implementations.Users public async Task InitializeAsync() { // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - if (_users.Any()) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - return; - } + if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) + { + return; + } - var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) - { - defaultName = "MyJellyfinUser"; - } + var defaultName = Environment.UserName; + if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) + { + defaultName = "MyJellyfinUser"; + } - _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + _logger.LogWarning("No users, creating one with username {UserName}", defaultName); - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true); @@ -562,7 +717,6 @@ namespace Jellyfin.Server.Implementations.Users dbContext.Users.Add(newUser); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _users.Add(newUser.Id, newUser); } } @@ -599,124 +753,120 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .AsSingleQuery() - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - user.SubtitleMode = config.SubtitleMode; - user.HidePlayedInLatest = config.HidePlayedInLatest; - user.EnableLocalPassword = config.EnableLocalPassword; - user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; - user.DisplayCollectionsView = config.DisplayCollectionsView; - user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; - user.AudioLanguagePreference = config.AudioLanguagePreference; - user.RememberAudioSelections = config.RememberAudioSelections; - user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; - user.RememberSubtitleSelections = config.RememberSubtitleSelections; - user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; - - // Only set cast receiver id if it is passed in and it exists in the server config. - if (!string.IsNullOrEmpty(config.CastReceiverId) - && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) - { - user.CastReceiverId = config.CastReceiverId; - } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); + + user.SubtitleMode = config.SubtitleMode; + user.HidePlayedInLatest = config.HidePlayedInLatest; + user.EnableLocalPassword = config.EnableLocalPassword; + user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; + user.DisplayCollectionsView = config.DisplayCollectionsView; + user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; + user.AudioLanguagePreference = config.AudioLanguagePreference; + user.RememberAudioSelections = config.RememberAudioSelections; + user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; + user.RememberSubtitleSelections = config.RememberSubtitleSelections; + user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + + // Only set cast receiver id if it is passed in and it exists in the server config. + if (!string.IsNullOrEmpty(config.CastReceiverId) + && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) + { + user.CastReceiverId = config.CastReceiverId; + } - user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); - user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); - user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); - user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); + user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); + user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); + user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); - dbContext.Update(user); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } } /// <inheritdoc/> public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .AsSingleQuery() - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" - int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch - { - -1 => null, - 0 => 3, - _ => policy.LoginAttemptsBeforeLockout - }; + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); - user.MaxParentalRatingScore = policy.MaxParentalRating; - user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; - user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; - user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; - user.AuthenticationProviderId = policy.AuthenticationProviderId; - user.PasswordResetProviderId = policy.PasswordResetProviderId; - user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; - user.LoginAttemptsBeforeLockout = maxLoginAttempts; - user.MaxActiveSessions = policy.MaxActiveSessions; - user.SyncPlayAccess = policy.SyncPlayAccess; - user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); - user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); - user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); - user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); - user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); - user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); - user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); - user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); - user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); - user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); - user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); - user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); - user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); - user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); - user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); - user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); - user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); - user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); - user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); - user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); - user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); - user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); - - user.AccessSchedules.Clear(); - foreach (var policyAccessSchedule in policy.AccessSchedules) - { - user.AccessSchedules.Add(policyAccessSchedule); - } + // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" + int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch + { + -1 => null, + 0 => 3, + _ => policy.LoginAttemptsBeforeLockout + }; + + user.MaxParentalRatingScore = policy.MaxParentalRating; + user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; + user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; + user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; + user.AuthenticationProviderId = policy.AuthenticationProviderId; + user.PasswordResetProviderId = policy.PasswordResetProviderId; + user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; + user.LoginAttemptsBeforeLockout = maxLoginAttempts; + user.MaxActiveSessions = policy.MaxActiveSessions; + user.SyncPlayAccess = policy.SyncPlayAccess; + user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); + user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); + user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); + user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); + user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); + user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); + user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); + user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); + user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); + user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); + user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); + user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); + user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); + user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); + user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); + user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); + user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); + user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); + user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); + user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); + user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); + user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + + user.AccessSchedules.Clear(); + foreach (var policyAccessSchedule in policy.AccessSchedules) + { + user.AccessSchedules.Add(policyAccessSchedule); + } - // TODO: fix this at some point - user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); - user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); - user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); - user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); - - dbContext.Update(user); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); + // TODO: fix this at some point + user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); + user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); + user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); + user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); + + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } } @@ -728,15 +878,17 @@ namespace Jellyfin.Server.Implementations.Users return; } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.Remove(user.ProfileImage); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } - user.ProfileImage = null; - _users[user.Id] = user; + user.ProfileImage = null; + } } internal static void ThrowIfInvalidUsername(string name) @@ -868,29 +1020,95 @@ namespace Jellyfin.Server.Implementations.Users } } - private async Task IncrementInvalidLoginAttemptCount(User user) + private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) + { + dbContext.Users.Attach(user); + dbContext.Entry(user).State = EntityState.Modified; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + /// <inheritdoc/> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Disposes all members of this class. + /// </summary> + /// <param name="disposing">Defines if the class has been cleaned up by a dispose or finalizer.</param> + protected virtual void Dispose(bool disposing) { - user.InvalidLoginAttemptCount++; - int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; - if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + if (disposing) { - 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); + _userLock.Dispose(); } - - await UpdateUserAsync(user).ConfigureAwait(false); } - private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) + internal sealed class LockHelper : IDisposable { - dbContext.Users.Attach(user); - dbContext.Entry(user).State = EntityState.Modified; - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); + 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/Configuration/StartupMode.cs b/Jellyfin.Server/Configuration/StartupMode.cs new file mode 100644 index 0000000000..e1d18f1dd6 --- /dev/null +++ b/Jellyfin.Server/Configuration/StartupMode.cs @@ -0,0 +1,24 @@ +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.Server.Configuration; + +/// <summary> +/// Defines types for usage with the <see cref="StartupOptions.StartupMode"/>. +/// </summary> +public enum StartupMode +{ + /// <summary> + /// Default startup mode, runs the jellyfin server in normal operation. + /// </summary> + MediaServer = 0, + + /// <summary> + /// Attempts to Migrate the system only then shuts down. + /// </summary> + MigrateSystem = 1, + + /// <summary> + /// Runs the Database seed function regardless of <see cref="BaseApplicationConfiguration.IsStartupWizardCompleted"/> state. + /// </summary> + SeedSystem = 2 +} 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 188d3c4a9a..a10be76e05 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -90,7 +90,7 @@ internal class JellyfinMigrationService private HashSet<MigrationStage> Migrations { get; set; } - public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths) + public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths, StartupOptions startupOptions) { var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup"); logger.LogInformation("Initialise Migration service."); @@ -98,9 +98,9 @@ internal class JellyfinMigrationService var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath) ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)! : new ServerConfiguration(); - if (!serverConfig.IsStartupWizardCompleted) + if (!serverConfig.IsStartupWizardCompleted || startupOptions.StartupMode is Configuration.StartupMode.SeedSystem) { - logger.LogInformation("System initialisation detected. Seed data."); + logger.LogInformation("System initialization detected. Seed data. Startup mode is: {StartupMode}", startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer); var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray(); var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); @@ -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 fbf9c16377..cfc1628782 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs @@ -144,6 +144,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine } var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension); + if (newSubtitleCachePath is null) + { + continue; + } + if (File.Exists(newSubtitleCachePath)) { File.Delete(oldSubtitleCachePath); @@ -182,6 +187,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine } var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture)); + if (newAttachmentPath is null) + { + continue; + } + if (File.Exists(newAttachmentPath)) { File.Delete(oldAttachmentPath); 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 14ae535531..c433c1d043 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -222,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(); @@ -283,9 +313,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); - _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count); + _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted); } private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context) @@ -314,9 +344,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); - _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count); + _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted); } private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context) @@ -343,9 +373,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); - _logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count); + _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted); } private void CleanupStaleFileEntries(JellyfinDbContext context) @@ -431,9 +461,34 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); + + _logger.LogInformation("Removed {Count} stale items.", deleted); + } + + private int DeleteItems(IReadOnlyCollection<BaseItem> items) + { + if (items.Count == 0) + { + return 0; + } + + var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false }; + var deleted = 0; + foreach (var item in items) + { + try + { + _libraryManager.DeleteItem(item, options); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unknown"); + } + } - _logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count); + return deleted; } private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) 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 2a6db01cf3..ed92c34aa3 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using Jellyfin.Database.Implementations; using Jellyfin.Server.ServerSetupApp; @@ -12,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// Migrate rating levels. /// </summary> #pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))] +[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))] [JellyfinMigrationBackup(JellyfinDb = true)] #pragma warning restore CS0618 // Type or member is obsolete internal class MigrateRatingLevels : IDatabaseMigrationRoutine diff --git a/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs new file mode 100644 index 0000000000..bff6ebbfb0 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs @@ -0,0 +1,216 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges MusicArtist records that differ only by Name casing. Prior to the case-insensitive +/// dedup lookup added alongside this migration, the artist validator would create a second +/// MusicArtist whenever a track tagged the artist with a different casing than the +/// resolver-created one (e.g. "Thirty Seconds To Mars" vs. "Thirty Seconds to Mars"). +/// </summary> +[JellyfinMigration("2026-05-08T12:00:00", nameof(MergeDuplicateMusicArtists))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine +{ + private const string MusicArtistType = "MediaBrowser.Controller.Entities.Audio.MusicArtist"; + + private readonly IStartupLogger<MergeDuplicateMusicArtists> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicateMusicArtists"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicateMusicArtists( + IStartupLogger<MergeDuplicateMusicArtists> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var artists = await context.BaseItems + .Where(b => b.Type == MusicArtistType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = artists + .GroupBy(a => a.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate MusicArtist records found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate MusicArtist records.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the artist with the most child references is the "real" one + // (the resolver-created artist with a filesystem path); the duplicates are usually + // empty stubs created by the validator's case-sensitive miss. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + ChildCount = context.BaseItems.Count(c => c.ParentId == b.Id), + AncestorCount = context.AncestorIds.Count(a => a.ParentItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.ChildCount) + .ThenByDescending(s => s.AncestorCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // AncestorIds PK is (ItemId, ParentItemId); drop rows that would collide before redirecting. + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // LinkedChildren PK is (ParentId, ChildId); drop colliding rows in both directions. + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // UserData has UNIQUE(UserId, CustomDataKey); keep the dup's row only when the + // keeper has no equivalent row, otherwise the keeper's value wins. + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged duplicates for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // 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. + // 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) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs new file mode 100644 index 0000000000..f28c804d26 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs @@ -0,0 +1,312 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges case-only duplicate people. Two passes: +/// 1) Person BaseItems whose Name differs only by casing — Person.GetPath hashes the name +/// verbatim, so two casings produce two distinct Person rows in BaseItems. +/// 2) Peoples lookup rows whose Name differs only by casing within the same PersonType — +/// UpdatePeople used to insert a second Peoples row when a metadata provider returned +/// a different casing than the row already in the table. +/// Both bugs cause the /Persons endpoint to list the same person twice. +/// </summary> +[JellyfinMigration("2026-05-08T13:00:00", nameof(MergeDuplicatePeople))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicatePeople : IAsyncMigrationRoutine +{ + private const string PersonType = "MediaBrowser.Controller.Entities.Person"; + + private readonly IStartupLogger<MergeDuplicatePeople> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicatePeople"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicatePeople( + IStartupLogger<MergeDuplicatePeople> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await MergePersonBaseItemsAsync(context, cancellationToken).ConfigureAwait(false); + await MergePeoplesRowsAsync(context, cancellationToken).ConfigureAwait(false); + } + } + + private async Task MergePersonBaseItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var persons = await context.BaseItems + .Where(b => b.Type == PersonType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = persons + .GroupBy(p => p.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Person BaseItems found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Person BaseItems.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the Person with the most UserData rows (favorites, image + // refresh state) is the one users have actually interacted with. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + UserDataCount = context.UserData.Count(u => u.ItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.UserDataCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Person BaseItems for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the + // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind. + // 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) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } + } + + private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var people = await context.Peoples + .Select(p => new { p.Id, p.Name, p.PersonType }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = people + .GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType)) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Peoples rows found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the row referenced by the most BaseItems is the one most + // tracks/movies already point at; the duplicates are usually orphan stubs left + // by a casing-mismatched insert. + var stats = await context.Peoples + .Where(p => groupIds.Contains(p.Id)) + .Select(p => new + { + p.Id, + p.Name, + MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.MapCount) + .ThenBy(s => s.Id) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + // PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would + // collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so + // match nulls explicitly. + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId + && context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId + && k.ItemId == m.ItemId + && (k.Role == m.Role || (k.Role == null && m.Role == null)))) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + 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/DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs index 6edfcbcfd5..4b8ced90ac 100644 --- a/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs +++ b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs @@ -7,7 +7,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// <summary> /// Migration to disable legacy authorization in the system config. /// </summary> -[JellyfinMigration("2025-11-18T16:00:00", nameof(DisableLegacyAuthorization))] +[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] public class DisableLegacyAuthorization : IAsyncMigrationRoutine { private readonly IServerConfigurationManager _serverConfigurationManager; 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 93ba672535..12f92efb35 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -133,11 +133,13 @@ namespace Jellyfin.Server } } + SetupServer.ReportActivity(StartupActivity.CheckingStorage); StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check")); StartupHelpers.PerformStaticInitialization(); - await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false); + 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; @@ -214,13 +221,17 @@ namespace Jellyfin.Server { configurationCompleted = true; await _setupServer!.StopAsync().ConfigureAwait(false); - await _jellyfinHost.StartAsync().ConfigureAwait(false); - if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) { - var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + await _jellyfinHost.StartAsync().ConfigureAwait(false); - StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); + if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) + { + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + + StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); + } } } catch (Exception) @@ -229,11 +240,14 @@ namespace Jellyfin.Server throw; } - await appHost.RunStartupTasksAsync().ConfigureAwait(false); + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) + { + await appHost.RunStartupTasksAsync().ConfigureAwait(false); + _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); - _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); + await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); + } - await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); _restartOnShutdown = appHost.ShouldRestart; _restoreFromBackup = appHost.RestoreBackupPath; } @@ -244,7 +258,11 @@ namespace Jellyfin.Server if (_setupServer!.IsAlive && !configurationCompleted) { _setupServer!.SoftStop(); - await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + if (options.StartupMode is null or Configuration.StartupMode.MediaServer) + { + await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + } + await _setupServer!.StopAsync().ConfigureAwait(false); } } @@ -275,8 +293,9 @@ namespace Jellyfin.Server /// </remarks> /// <param name="appPaths">Application Paths.</param> /// <param name="startupConfig">Startup Config.</param> + /// <param name="startupOptions">The applications startup options.</param> /// <returns>A task.</returns> - public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig) + public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig, StartupOptions startupOptions) { _migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service"); var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer()); @@ -294,7 +313,7 @@ namespace Jellyfin.Server PrepareDatabaseProvider(startupService); var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService); - await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths).ConfigureAwait(false); + await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false); await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false); } 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/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index f6a4ae7d6e..1802440dc4 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Net; using System.Net.Http; @@ -6,6 +7,7 @@ using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using Emby.Server.Implementations.EntryPoints; +using Emby.Server.Implementations.Localization; using Jellyfin.Api.Middleware; using Jellyfin.Database.Implementations; using Jellyfin.LiveTv.Extensions; @@ -22,6 +24,7 @@ using MediaBrowser.Controller.Extensions; using MediaBrowser.XbmcMetadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -127,6 +130,25 @@ namespace Jellyfin.Server services.AddHlsPlaylistGenerator(); services.AddLiveTvServices(); + var serverUICulture = _serverConfigurationManager.Configuration.UICulture; + if (string.IsNullOrEmpty(serverUICulture)) + { + serverUICulture = "en-US"; + } + + CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(serverUICulture); + + services.Configure<RequestLocalizationOptions>(options => + { + var supportedUICultures = LocalizationManager.GetSupportedUICultures(); + options.SupportedCultures = supportedUICultures; + options.SupportedUICultures = supportedUICultures; + options.DefaultRequestCulture = new RequestCulture(serverUICulture); + options.ApplyCurrentCultureToResponseHeaders = true; + options.FallBackToParentCultures = true; + options.FallBackToParentUICultures = true; + }); + services.AddHostedService<RecordingsHost>(); services.AddHostedService<AutoDiscoveryHost>(); services.AddHostedService<NfoUserDataSaver>(); @@ -168,6 +190,8 @@ namespace Jellyfin.Server mainApp.UseCors(); + mainApp.UseRequestLocalization(); + if (config.RequireHttps && _serverApplicationHost.ListenWithHttps) { mainApp.UseHttpsRedirection(); diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index 4890ccbb2e..4716bc1746 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using CommandLine; using Emby.Server.Implementations; +using Jellyfin.Server.Configuration; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; namespace Jellyfin.Server @@ -80,6 +81,13 @@ namespace Jellyfin.Server public string? RestoreArchive { get; set; } /// <summary> + /// Gets or sets the mode of operation the server should perform when started. + /// Defaults to: <see cref="StartupMode.MediaServer"/>. + /// </summary> + [Option("mode", Required = false, HelpText = "Mode which selects what action the jellyfin server should perform when started.")] + public StartupMode? StartupMode { get; set; } + + /// <summary> /// Gets the command line options as a dictionary that can be used in the .NET configuration system. /// </summary> /// <returns>The configuration dictionary.</returns> diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index c128c2b6bb..de07c7f2cb 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Common</PackageId> - <VersionPrefix>10.12.0</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Common/Net/NetworkUtils.cs b/MediaBrowser.Common/Net/NetworkUtils.cs index 5c854b39d5..25a1022d4e 100644 --- a/MediaBrowser.Common/Net/NetworkUtils.cs +++ b/MediaBrowser.Common/Net/NetworkUtils.cs @@ -7,6 +7,7 @@ using System.Net.Sockets; using System.Text.RegularExpressions; using Jellyfin.Extensions; using MediaBrowser.Model.Net; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Common.Net; @@ -166,8 +167,9 @@ public static partial class NetworkUtils /// <param name="values">Input string array to be parsed.</param> /// <param name="result">Collection of <see cref="IPNetwork"/>.</param> /// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param> + /// <param name="logger">Optional logger used to warn about entries that fail to parse.</param> /// <returns><c>True</c> if parsing was successful.</returns> - public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false) + public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false, ILogger? logger = null) { if (values is null || values.Length == 0) { @@ -178,9 +180,20 @@ public static partial class NetworkUtils List<IPData>? tmpResult = null; for (int a = 0; a < values.Length; a++) { + // Skip entries whose '!' polarity doesn't match this pass + var trimmed = values[a].AsSpan().Trim(); + if (trimmed.StartsWith('!') != negated) + { + continue; + } + if (TryParseToSubnet(values[a], out var innerResult, negated)) { - (tmpResult ??= new()).Add(innerResult); + (tmpResult ??= []).Add(innerResult); + } + else + { + LogInvalidSubnet(logger, values[a]); } } @@ -188,6 +201,35 @@ public static partial class NetworkUtils return result is not null; } + private static void LogInvalidSubnet(ILogger? logger, string value) + { + if (logger is null) + { + return; + } + + var trimmed = value.AsSpan().Trim(); + if (trimmed.StartsWith('!')) + { + trimmed = trimmed[1..]; + } + + var slash = trimmed.IndexOf('/'); + if (slash != -1 + && trimmed.Contains(':') + && trimmed.IndexOf("::", StringComparison.Ordinal) == -1) + { + logger.LogWarning( + "Invalid IPv6 subnet '{Subnet}': IPv6 prefix-only notation is not supported. Use the full notation including '::' (e.g. '{Example}::/{Prefix}').", + value, + trimmed[..slash].ToString(), + trimmed[(slash + 1)..].ToString()); + return; + } + + logger.LogWarning("Invalid subnet '{Subnet}' will be ignored.", value); + } + /// <summary> /// Try parsing a string into an <see cref="IPData"/>, respecting exclusions. /// Inputs without a subnet mask will be represented as <see cref="IPData"/> with a single IP. 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 822b21c062..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() @@ -216,6 +219,13 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public string OriginalTitle { get; set; } + [JsonIgnore] + public string OriginalLanguage + { + get => _originalLanguage; + set => _originalLanguage = LocalizationManager?.FindLanguageInfo(value)?.TwoLetterISOLanguageName ?? value; + } + /// <summary> /// Gets or sets the id. /// </summary> @@ -1091,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()) { @@ -1102,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() @@ -1120,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 { @@ -1141,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, @@ -1220,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>(); @@ -1228,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()); @@ -1290,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); @@ -1561,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() @@ -1595,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) @@ -2002,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> @@ -2709,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) @@ -2719,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)] }); } @@ -2739,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/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/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..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 = []; @@ -58,6 +59,8 @@ namespace MediaBrowser.Controller.Entities VideoTypes = []; Years = []; SkipDeserialization = false; + AudioLanguages = []; + SubtitleLanguages = []; } public InternalItemsQuery(User? user) @@ -69,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; } @@ -263,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; } @@ -351,6 +456,8 @@ namespace MediaBrowser.Controller.Entities public Dictionary<string, string>? HasAnyProviderId { get; set; } + public Dictionary<string, string[]>? HasAnyProviderIds { get; set; } + public Guid[] AlbumArtistIds { get; set; } public Guid[] BoxSetLibraryFolders { get; set; } @@ -385,6 +492,10 @@ namespace MediaBrowser.Controller.Entities public bool IncludeExtras { get; set; } + public IReadOnlyList<string> AudioLanguages { get; set; } + + public IReadOnlyList<string> SubtitleLanguages { get; set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; 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/IO/IPathManager.cs b/MediaBrowser.Controller/IO/IPathManager.cs index eb67437545..30961c7610 100644 --- a/MediaBrowser.Controller/IO/IPathManager.cs +++ b/MediaBrowser.Controller/IO/IPathManager.cs @@ -22,30 +22,30 @@ public interface IPathManager /// <param name="mediaSourceId">The media source id.</param> /// <param name="streamIndex">The stream index.</param> /// <param name="extension">The subtitle file extension.</param> - /// <returns>The absolute path.</returns> - public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension); /// <summary> /// Gets the path to the subtitle file. /// </summary> /// <param name="mediaSourceId">The media source id.</param> - /// <returns>The absolute path.</returns> - public string GetSubtitleFolderPath(string mediaSourceId); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetSubtitleFolderPath(string mediaSourceId); /// <summary> /// Gets the path to the attachment file. /// </summary> /// <param name="mediaSourceId">The media source id.</param> /// <param name="fileName">The attachmentFileName index.</param> - /// <returns>The absolute path.</returns> - public string GetAttachmentPath(string mediaSourceId, string fileName); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetAttachmentPath(string mediaSourceId, string fileName); /// <summary> /// Gets the path to the attachment folder. /// </summary> /// <param name="mediaSourceId">The media source id.</param> - /// <returns>The absolute path.</returns> - public string GetAttachmentFolderPath(string mediaSourceId); + /// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns> + public string? GetAttachmentFolderPath(string mediaSourceId); /// <summary> /// Gets the chapter images data path. 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 f5e3d7034e..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> @@ -784,5 +792,12 @@ namespace MediaBrowser.Controller.Library /// <param name="query">The query filter.</param> /// <returns>Aggregated filter values.</returns> QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); + + /// <summary> + /// Gets a list of all language codes of the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The stream type.</param> + /// <returns>List of language codes.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); } } diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..b8e41ec810 --- /dev/null +++ b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Provides similar items from the local library. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// </summary> +public interface ILocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Determines whether the provider can handle items of the specified type. + /// </summary> + /// <param name="itemType">The item type.</param> + /// <returns><c>true</c> if the provider handles this item type; otherwise <c>false</c>.</returns> + bool Supports(Type itemType); + + /// <summary> + /// Gets similar items from the local library. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions, etc.).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The list of similar items from the library.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + +/// <summary> +/// Provides similar items from the local library for a specific item type. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// </summary> +/// <typeparam name="TItemType">The type of item this provider handles.</typeparam> +public interface ILocalSimilarItemsProvider<TItemType> : ILocalSimilarItemsProvider + where TItemType : BaseItem +{ + /// <summary> + /// Gets similar items from the local library. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions, etc.).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The list of similar items from the library.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs new file mode 100644 index 0000000000..3803e51769 --- /dev/null +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Provides similar item references from remote/external sources. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// </summary> +public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Determines whether the provider can handle items of the specified type. + /// </summary> + /// <param name="itemType">The item type.</param> + /// <returns><c>true</c> if the provider handles this item type; otherwise <c>false</c>.</returns> + bool Supports(Type itemType); + + /// <summary> + /// Gets similar item references from an external source as an async stream. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async enumerable of similar item references.</returns> + IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + +/// <summary> +/// Provides similar item references from remote/external sources for a specific item type. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// </summary> +/// <typeparam name="TItemType">The type of item this provider handles.</typeparam> +public interface IRemoteSimilarItemsProvider<TItemType> : IRemoteSimilarItemsProvider + where TItemType : BaseItem +{ + /// <summary> + /// Gets similar item references from an external source as an async stream. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async enumerable of similar item references.</returns> + IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); + + bool IRemoteSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + IAsyncEnumerable<SimilarItemReference> IRemoteSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); +} 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 new file mode 100644 index 0000000000..36fa547eeb --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +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; + +/// <summary> +/// Interface for managing similar items providers and operations. +/// </summary> +public interface ISimilarItemsManager +{ + /// <summary> + /// Registers similar items providers discovered through dependency injection. + /// </summary> + /// <param name="providers">The similar items providers to register.</param> + void AddParts(IEnumerable<ISimilarItemsProvider> providers); + + /// <summary> + /// Gets the similar items providers for a specific item type. + /// </summary> + /// <typeparam name="T">The item type.</typeparam> + /// <returns>The list of similar items providers for that type.</returns> + IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem; + + /// <summary> + /// Gets similar items for the specified item. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="excludeArtistIds">Artist IDs to exclude from results.</param> + /// <param name="user">The user context.</param> + /// <param name="dtoOptions">The DTO options.</param> + /// <param name="limit">Maximum number of results.</param> + /// <param name="libraryOptions">The library options for provider configuration.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The list of similar items.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + 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/ISimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs new file mode 100644 index 0000000000..0d089369a8 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Base marker interface for similar items providers. +/// </summary> +public interface ISimilarItemsProvider +{ + /// <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 cache duration for results from this provider. + /// If null, results will not be cached. + /// </summary> + TimeSpan? CacheDuration => null; +} 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/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 0109cf4b7d..e2b54ea7a7 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the users. /// </summary> - /// <value>The users.</value> - IEnumerable<User> Users { get; } + /// <returns>The users.</returns> + IEnumerable<User> GetUsers(); /// <summary> /// Gets the user ids. /// </summary> - /// <value>The users ids.</value> - IEnumerable<Guid> UsersIds { get; } + /// <returns>The users ids.</returns> + IEnumerable<Guid> GetUsersIds(); /// <summary> /// Initializes the user manager and ensures that a user exists. @@ -48,6 +48,12 @@ namespace MediaBrowser.Controller.Library User? GetUserById(Guid id); /// <summary> + /// Gets the first available user. + /// </summary> + /// <returns>The first user, or <c>null</c> if no users exist.</returns> + User? GetFirstUser(); + + /// <summary> /// Gets the name of the user by. /// </summary> /// <param name="name">The name.</param> @@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Renames the user. /// </summary> - /// <param name="user">The user.</param> + /// <param name="userId">The UserId to change.</param> + /// <param name="oldName">The old Username.</param> /// <param name="newName">The new name.</param> /// <returns>Task.</returns> /// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception> /// <exception cref="ArgumentException">If the provided user doesn't exist.</exception> - Task RenameUser(User user, string newName); + Task RenameUser(Guid userId, string oldName, string newName); /// <summary> /// Updates the user. @@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Resets the password. /// </summary> - /// <param name="user">The user.</param> + /// <param name="userId">The users Id.</param> /// <returns>Task.</returns> - Task ResetPassword(User user); + Task ResetPassword(Guid userId); /// <summary> /// Changes the password. /// </summary> - /// <param name="user">The user.</param> + /// <param name="userId">The users id.</param> /// <param name="newPassword">New password to use.</param> /// <returns>Awaitable task.</returns> - Task ChangePassword(User user, string newPassword); + Task ChangePassword(Guid userId, string newPassword); /// <summary> /// Gets the user dto. 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/SimilarItemReference.cs b/MediaBrowser.Controller/Library/SimilarItemReference.cs new file mode 100644 index 0000000000..2a40c93bdd --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemReference.cs @@ -0,0 +1,22 @@ +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// A reference to a similar item by provider ID with a similarity score. +/// </summary> +public class SimilarItemReference +{ + /// <summary> + /// Gets or sets the provider name (e.g., "Tmdb", "MusicBrainzArtist"). + /// </summary> + public required string ProviderName { get; set; } + + /// <summary> + /// Gets or sets the provider ID value. + /// </summary> + public required string ProviderId { get; set; } + + /// <summary> + /// Gets or sets the similarity score (0.0 to 1.0). + /// </summary> + public float? Score { get; set; } +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsQuery.cs b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs new file mode 100644 index 0000000000..1ed3ceec16 --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Query options for similar items requests. +/// </summary> +public class SimilarItemsQuery +{ + /// <summary> + /// Gets or sets the user context. + /// </summary> + public User? User { get; set; } + + /// <summary> + /// Gets or sets the maximum number of results. + /// </summary> + public int? Limit { get; set; } + + /// <summary> + /// Gets or sets the DTO options. + /// </summary> + public DtoOptions? DtoOptions { get; set; } + + /// <summary> + /// Gets or sets the item IDs to exclude from results. + /// </summary> + public IReadOnlyList<Guid> ExcludeItemIds { get; set; } = []; + + /// <summary> + /// Gets or sets the artist IDs to exclude from results. + /// </summary> + public IReadOnlyList<Guid> ExcludeArtistIds { get; set; } = []; +} 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/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 0025080cc9..06188ad511 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Controller</PackageId> - <VersionPrefix>10.12.0</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index 10f2f04af6..34826982af 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -92,6 +92,12 @@ namespace MediaBrowser.Controller.MediaEncoding public string CodecTag { get; set; } /// <summary> + /// Gets or sets the rotation. + /// </summary> + /// <value>The video rotation angle, usually 0 or +-90/180.</value> + public string Rotation { get; set; } + + /// <summary> /// Gets or sets the framerate. /// </summary> /// <value>The framerate.</value> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index a0e04eae63..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)) { - bitStreamArgs = GetBitStreamArgs(state, MediaStreamType.Audio); - bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs; + filters.Add("aac_adtstoasc"); } - return bitStreamArgs; + 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) + { + return null; + } + + 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) @@ -1645,10 +1692,9 @@ namespace MediaBrowser.Controller.MediaEncoding } if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)) { - // Override the too high default qmin 18 in transcoding preset + // Override the too high default qmin 18 in transcoding preset in legacy h26x_amf return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } @@ -1767,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) @@ -1783,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) @@ -1807,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" }; } @@ -1880,10 +1926,12 @@ namespace MediaBrowser.Controller.MediaEncoding var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty; var fontPath = _pathManager.GetAttachmentFolderPath(state.MediaSource.Id); - var fontParam = string.Format( - CultureInfo.InvariantCulture, - ":fontsdir='{0}'", - _mediaEncoder.EscapeSubtitleFilterPath(fontPath)); + var fontParam = fontPath is null + ? string.Empty + : string.Format( + CultureInfo.InvariantCulture, + ":fontsdir='{0}'", + _mediaEncoder.EscapeSubtitleFilterPath(fontPath)); if (state.SubtitleStream.IsExternal) { @@ -2016,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; @@ -2466,6 +2518,17 @@ namespace MediaBrowser.Controller.MediaEncoding } } + var requestedRotations = state.GetRequestedRotations(videoStream.Codec); + if (requestedRotations.Length > 0) + { + var rotation = state.VideoStream?.Rotation ?? 0; + if (rotation != 0 + && !requestedRotations.Contains(rotation.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) + { + return false; + } + } + // Video width must fall within requested value if (request.MaxWidth.HasValue && (!videoStream.Width.HasValue || videoStream.Width.Value > request.MaxWidth.Value)) @@ -2549,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 @@ -2606,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) @@ -2750,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 @@ -2989,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; @@ -3060,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, @@ -3092,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) { @@ -3792,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; @@ -3946,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); @@ -3982,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); @@ -4157,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); @@ -4405,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; @@ -4699,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; @@ -5030,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; @@ -5267,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; @@ -5507,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); @@ -5740,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); @@ -5941,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); @@ -6207,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; @@ -7163,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"; @@ -7178,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); + } } } @@ -7797,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, @@ -7811,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 @@ -7874,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 7d0384ef27..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); @@ -571,62 +580,50 @@ namespace MediaBrowser.Controller.MediaEncoding public string[] GetRequestedProfiles(string codec) { - if (!string.IsNullOrEmpty(BaseRequest.Profile)) - { - return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + var profile = BaseRequest.Profile; - if (!string.IsNullOrEmpty(codec)) + if (string.IsNullOrEmpty(profile) && !string.IsNullOrEmpty(codec)) { - var profile = BaseRequest.GetOption(codec, "profile"); - - if (!string.IsNullOrEmpty(profile)) - { - return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + profile = BaseRequest.GetOption(codec, "profile"); } - return Array.Empty<string>(); + return (profile ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); } public string[] GetRequestedRangeTypes(string codec) { - if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType)) - { - return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + var rangetype = BaseRequest.VideoRangeType; - if (!string.IsNullOrEmpty(codec)) + if (string.IsNullOrEmpty(rangetype) && !string.IsNullOrEmpty(codec)) { - var rangetype = BaseRequest.GetOption(codec, "rangetype"); - - if (!string.IsNullOrEmpty(rangetype)) - { - return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + rangetype = BaseRequest.GetOption(codec, "rangetype"); } - return Array.Empty<string>(); + return (rangetype ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); } public string[] GetRequestedCodecTags(string codec) { - if (!string.IsNullOrEmpty(BaseRequest.CodecTag)) + var codectag = BaseRequest.CodecTag; + + if (string.IsNullOrEmpty(codectag) && !string.IsNullOrEmpty(codec)) { - return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); + codectag = BaseRequest.GetOption(codec, "codectag"); } - if (!string.IsNullOrEmpty(codec)) - { - var codectag = BaseRequest.GetOption(codec, "codectag"); + return (codectag ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); + } - if (!string.IsNullOrEmpty(codectag)) - { - return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries); - } + public string[] GetRequestedRotations(string codec) + { + var rotation = BaseRequest.Rotation; + + if (string.IsNullOrEmpty(rotation) && !string.IsNullOrEmpty(codec)) + { + rotation = BaseRequest.GetOption(codec, "rotation"); } - return Array.Empty<string>(); + return (rotation ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries); } public string GetRequestedLevel(string codec) 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/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 1e0d77fe51..2bcce168cf 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -40,11 +40,6 @@ namespace MediaBrowser.Controller.Net /// </summary> private readonly List<(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)> _activeConnections = new(); - /// <summary> - /// The logger. - /// </summary> - protected readonly ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; - private readonly Task _messageConsumerTask; protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) @@ -57,6 +52,11 @@ namespace MediaBrowser.Controller.Net } /// <summary> + /// Gets the Logger. + /// </summary> + protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger { get; } + + /// <summary> /// Gets the type used for the messages sent to the client. /// </summary> /// <value>The type.</value> @@ -209,6 +209,11 @@ namespace MediaBrowser.Controller.Net var (connection, cts, state) = tuple; var cancellationToken = cts.Token; + // Restore the culture context captured when the connection was established + // so that GetDataToSendForConnection produces a localized payload matching + // the client's Accept-Language preference rather than the server default. + connection.ApplyRequestCulture(); + var data = await GetDataToSendForConnection(connection).ConfigureAwait(false); if (data is null) { diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index bdc0f9a10f..48431e75c3 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -77,5 +77,14 @@ namespace MediaBrowser.Controller.Net /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> Task ReceiveAsync(CancellationToken cancellationToken = default); + + /// <summary> + /// Applies the culture context captured when the connection was established + /// (from the upgrade request's <c>Accept-Language</c> header) to the current + /// async flow. Server-initiated message senders should call this before + /// localising any payload so that the response uses the client's preferred + /// language rather than the server default. + /// </summary> + void ApplyRequestCulture(); } } 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/IMediaStreamRepository.cs b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs index 665129eafd..de04ff021d 100644 --- a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs +++ b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs @@ -22,6 +22,13 @@ public interface IMediaStreamRepository IReadOnlyList<MediaStream> GetMediaStreams(MediaStreamQuery filter); /// <summary> + /// Gets all language codes of the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The type of the media stream.</param> + /// <returns>IEnumerable{string}.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); + + /// <summary> /// Saves the media streams. /// </summary> /// <param name="id">The identifier.</param> 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/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 5b5af75a47..6060d051a5 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -105,7 +105,7 @@ namespace MediaBrowser.Controller.Providers public IReadOnlyList<string> GetFilePaths(string path) => GetFilePaths(path, false); - public IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false) + public IReadOnlyList<string> GetFilePaths(string path, bool clearCache) { if (clearCache) { @@ -118,7 +118,7 @@ namespace MediaBrowser.Controller.Providers { try { - return fileSystem.GetFilePaths(p).ToList(); + return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList(); } catch (DirectoryNotFoundException) { @@ -127,11 +127,6 @@ namespace MediaBrowser.Controller.Providers }, _fileSystem); - if (sort) - { - filePaths.Sort(); - } - return filePaths; } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 1babf73af8..8a3fa33da3 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -21,7 +21,7 @@ namespace MediaBrowser.Controller.Providers IReadOnlyList<string> GetFilePaths(string path); - IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false); + IReadOnlyList<string> GetFilePaths(string path, bool clearCache); bool IsAccessible(string path); } 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/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 0d3a334dfb..c87f09a117 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -144,6 +144,17 @@ namespace MediaBrowser.Controller.Providers where T : BaseItem; /// <summary> + /// Gets the metadata providers for the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="includeDisabled">Whether to include disabled providers.</param> + /// <typeparam name="T">The type of metadata provider.</typeparam> + /// <returns>The metadata providers.</returns> + IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem; + + /// <summary> /// Gets the metadata savers for the provided item. /// </summary> /// <param name="item">The item.</param> 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 f7a1581a76..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, @@ -129,6 +262,12 @@ namespace MediaBrowser.MediaEncoding.Attachments 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)) { var directory = Directory.CreateDirectory(outputFolder); @@ -157,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); @@ -241,9 +380,14 @@ namespace MediaBrowser.MediaEncoding.Attachments CancellationToken cancellationToken) { var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id); + if (attachmentFolderPath is null) + { + throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no attachment cache (non-GUID Id, e.g. Live TV stream)."); + } + using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) { - var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture)); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!; if (!File.Exists(attachmentPath)) { await ExtractAttachmentInternal( @@ -284,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..c8670c67cc 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; @@ -645,7 +646,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 f34e911a05..1199fd7d70 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; @@ -85,6 +86,8 @@ namespace MediaBrowser.MediaEncoding.Encoder private bool _isVaapiDeviceSupportVulkanDrmModifier = false; private bool _isVaapiDeviceSupportVulkanDrmInterop = false; + private bool _canSetProcessPriority = true; + private bool _isVideoToolboxAv1DecodeAvailable = false; private static string[] _vulkanImageDrmFmtModifierExts = @@ -526,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, @@ -1123,13 +1127,17 @@ namespace MediaBrowser.MediaEncoding.Encoder { process.Process.Start(); - try - { - process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; - } - catch (Exception ex) + if (_canSetProcessPriority) { - _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}", process.Process.StartInfo.FileName); + try + { + process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch (Exception ex) + { + _canSetProcessPriority = false; + _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}. Further attempts will be skipped.", process.Process.StartInfo.FileName); + } } lock (_runningProcessesLock) 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 a4d17e4f9d..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 @@ -729,6 +731,7 @@ namespace MediaBrowser.MediaEncoding.Probing stream.Type = MediaStreamType.Audio; stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); + stream.LocalizedOriginal = _localization.GetLocalizedString("Original"); stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) ? null : _localization.FindLanguageInfo(stream.Language)?.DisplayName; @@ -971,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)) @@ -1031,6 +1036,11 @@ namespace MediaBrowser.MediaEncoding.Probing { stream.IsHearingImpaired = true; } + + if (disposition.GetValueOrDefault("original") == 1) + { + stream.IsOriginal = true; + } } NormalizeStreamTitle(stream); @@ -1254,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; @@ -1624,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; } @@ -1702,6 +1719,13 @@ namespace MediaBrowser.MediaEncoding.Probing return; } + // Skip timestamp extration for remote resource (http, rtsp, etc.) + // as they cannot be opened with FileStream + if (video.Protocol != MediaProtocol.File) + { + return; + } + if (!string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase) && !string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase) && !string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase)) @@ -1751,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/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/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 894d0a3574..28f7b92268 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 { @@ -72,55 +75,42 @@ namespace MediaBrowser.MediaEncoding.Subtitles private 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 = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); - 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)); } } } @@ -212,19 +202,20 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream); var outputFormat = GetExtractableSubtitleFormat(subtitleStream); - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension) + ?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream)."); return new SubtitleInfo() { 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)) @@ -242,7 +233,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (!_subtitleParser.SupportsFileExtension(currentFormat)) { // Convert - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt"); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt") + ?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream)."); await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); @@ -265,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; } @@ -281,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; } @@ -309,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)) { @@ -331,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> @@ -373,96 +445,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - int exitCode; - - 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)); - } + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); - 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) @@ -473,6 +464,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles { return subtitleStream.Codec; } + else if (MediaStream.IsVobSubFormat(subtitleStream.Codec)) + { + return "mks"; + } else { return "srt"; @@ -486,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); @@ -498,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 /> @@ -514,16 +515,22 @@ 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; } var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream)); + if (outputPath is null) + { + continue; + } 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; @@ -580,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) @@ -591,7 +598,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles } var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream)); + if (outputPath is null) + { + continue; + } + 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) @@ -605,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); + } } } @@ -624,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) @@ -636,7 +656,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles } var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream)); + if (outputPath is null) + { + continue; + } + 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) @@ -650,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; @@ -770,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)); } @@ -827,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 { @@ -844,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 }, @@ -861,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) @@ -876,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); - } - 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); + standardError = await standardErrorTask.ConfigureAwait(false); } - 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> @@ -968,7 +956,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension) + private string? GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension) { return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension); } @@ -981,12 +969,16 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec); - await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken) - .ConfigureAwait(false); + var cachePath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec); + if (cachePath is not null) + { + path = cachePath; + await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken) + .ConfigureAwait(false); + } } - 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 @@ -1002,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/Channels/ChannelFeatures.cs b/MediaBrowser.Model/Channels/ChannelFeatures.cs index 1ca8e80a6f..57803c9765 100644 --- a/MediaBrowser.Model/Channels/ChannelFeatures.cs +++ b/MediaBrowser.Model/Channels/ChannelFeatures.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; namespace MediaBrowser.Model.Channels { @@ -8,9 +9,9 @@ namespace MediaBrowser.Model.Channels { public ChannelFeatures(string name, Guid id) { - MediaTypes = Array.Empty<ChannelMediaType>(); - ContentTypes = Array.Empty<ChannelMediaContentType>(); - DefaultSortFields = Array.Empty<ChannelItemSortField>(); + MediaTypes = []; + ContentTypes = []; + DefaultSortFields = []; Name = name; Id = id; @@ -38,13 +39,13 @@ namespace MediaBrowser.Model.Channels /// Gets or sets the media types. /// </summary> /// <value>The media types.</value> - public ChannelMediaType[] MediaTypes { get; set; } + public IReadOnlyList<ChannelMediaType> MediaTypes { get; set; } /// <summary> /// Gets or sets the content types. /// </summary> /// <value>The content types.</value> - public ChannelMediaContentType[] ContentTypes { get; set; } + public IReadOnlyList<ChannelMediaContentType> ContentTypes { get; set; } /// <summary> /// Gets or sets the maximum number of records the channel allows retrieving at a time. @@ -61,7 +62,7 @@ namespace MediaBrowser.Model.Channels /// Gets or sets the default sort orders. /// </summary> /// <value>The default sort orders.</value> - public ChannelItemSortField[] DefaultSortFields { get; set; } + public IReadOnlyList<ChannelItemSortField> DefaultSortFields { get; set; } /// <summary> /// Gets or sets a value indicating whether a sort ascending/descending toggle is supported. 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 670d6e3837..dd9a599a29 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -15,6 +15,9 @@ namespace MediaBrowser.Model.Configuration MetadataSaver, SubtitleFetcher, LyricFetcher, - MediaSegmentProvider + MediaSegmentProvider, + LocalSimilarityProvider, + SimilarityProvider, + SearchProvider } } diff --git a/MediaBrowser.Model/Configuration/TypeOptions.cs b/MediaBrowser.Model/Configuration/TypeOptions.cs index d0179e5aab..3aa85034e5 100644 --- a/MediaBrowser.Model/Configuration/TypeOptions.cs +++ b/MediaBrowser.Model/Configuration/TypeOptions.cs @@ -304,11 +304,13 @@ namespace MediaBrowser.Model.Configuration public TypeOptions() { - MetadataFetchers = Array.Empty<string>(); - MetadataFetcherOrder = Array.Empty<string>(); - ImageFetchers = Array.Empty<string>(); - ImageFetcherOrder = Array.Empty<string>(); - ImageOptions = Array.Empty<ImageOption>(); + MetadataFetchers = []; + MetadataFetcherOrder = []; + ImageFetchers = []; + ImageFetcherOrder = []; + ImageOptions = []; + SimilarItemProviders = []; + SimilarItemProviderOrder = []; } public string Type { get; set; } @@ -323,6 +325,10 @@ namespace MediaBrowser.Model.Configuration public ImageOption[] ImageOptions { get; set; } + public string[] SimilarItemProviders { get; set; } + + public string[] SimilarItemProviderOrder { get; set; } + public ImageOption GetImageOptions(ImageType type) { foreach (var i in ImageOptions) 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/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs index 79ee683a2d..a6018f369d 100644 --- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs +++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs @@ -33,6 +33,7 @@ namespace MediaBrowser.Model.Dlna /// <param name="numAudioStreams">The number of audio streams.</param> /// <param name="videoCodecTag">The video codec tag.</param> /// <param name="isAvc">A value indicating whether the video is AVC.</param> + /// <param name="videoRotation">The video rotation angle, usually 0 or +-90/180.</param> /// <returns><b>True</b> if the condition is satisfied.</returns> public static bool IsVideoConditionSatisfied( ProfileCondition condition, @@ -53,7 +54,8 @@ namespace MediaBrowser.Model.Dlna int? numVideoStreams, int? numAudioStreams, string? videoCodecTag, - bool? isAvc) + bool? isAvc, + int? videoRotation) { switch (condition.Property) { @@ -93,6 +95,8 @@ namespace MediaBrowser.Model.Dlna return IsConditionSatisfied(condition, numVideoStreams); case ProfileConditionValue.VideoTimestamp: return IsConditionSatisfied(condition, timestamp); + case ProfileConditionValue.VideoRotation: + return IsConditionSatisfied(condition, videoRotation); default: return true; } 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/ProfileConditionValue.cs b/MediaBrowser.Model/Dlna/ProfileConditionValue.cs index b66a15840b..c6171c7ab2 100644 --- a/MediaBrowser.Model/Dlna/ProfileConditionValue.cs +++ b/MediaBrowser.Model/Dlna/ProfileConditionValue.cs @@ -28,6 +28,7 @@ namespace MediaBrowser.Model.Dlna AudioSampleRate = 22, AudioBitDepth = 23, VideoRangeType = 24, - NumStreams = 25 + NumStreams = 25, + VideoRotation = 26 } } 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 c9697c685c..59f97d8c7c 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.Model.Dlna internal const TranscodeReason ContainerReasons = TranscodeReason.ContainerNotSupported | TranscodeReason.ContainerBitrateExceedsLimit; internal const TranscodeReason AudioCodecReasons = TranscodeReason.AudioBitrateNotSupported | TranscodeReason.AudioChannelsNotSupported | TranscodeReason.AudioProfileNotSupported | TranscodeReason.AudioSampleRateNotSupported | TranscodeReason.SecondaryAudioNotSupported | TranscodeReason.AudioBitDepthNotSupported | TranscodeReason.AudioIsExternal; internal const TranscodeReason AudioReasons = TranscodeReason.AudioCodecNotSupported | AudioCodecReasons; - internal const TranscodeReason VideoCodecReasons = TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.VideoProfileNotSupported; + internal const TranscodeReason VideoCodecReasons = TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.VideoProfileNotSupported | TranscodeReason.VideoRotationNotSupported; internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons; internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported; @@ -380,6 +380,9 @@ namespace MediaBrowser.Model.Dlna case ProfileConditionValue.VideoRangeType: return TranscodeReason.VideoRangeTypeNotSupported; + case ProfileConditionValue.VideoRotation: + return TranscodeReason.VideoRotationNotSupported; + case ProfileConditionValue.VideoTimestamp: // TODO return 0; @@ -572,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; } @@ -943,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; @@ -991,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; @@ -1040,6 +1056,7 @@ namespace MediaBrowser.Model.Dlna bool? isInterlaced = videoStream?.IsInterlaced; string? videoCodecTag = videoStream?.CodecTag; bool? isAvc = videoStream?.IsAVC; + int? videoRotation = videoStream?.Rotation; TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp; int? packetLength = videoStream?.PacketLength; @@ -1054,7 +1071,7 @@ namespace MediaBrowser.Model.Dlna var appliedVideoConditions = options.Profile.CodecProfiles .Where(i => i.Type == CodecType.Video && i.ContainsAnyCodec(playlistItem.VideoCodecs, container, useSubContainer) && - i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc))) + i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc, videoRotation))) // Reverse codec profiles for backward compatibility - first codec profile has higher priority .Reverse(); foreach (var condition in appliedVideoConditions) @@ -1447,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) @@ -1536,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) @@ -1560,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) { @@ -2059,6 +2096,38 @@ namespace MediaBrowser.Model.Dlna break; } + case ProfileConditionValue.VideoRotation: + { + if (string.IsNullOrEmpty(qualifier)) + { + continue; + } + + // change from split by | to comma + // strip spaces to avoid having to encode + var values = value + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (condition.Condition == ProfileConditionType.Equals) + { + item.SetOption(qualifier, "rotation", string.Join(',', values)); + } + else if (condition.Condition == ProfileConditionType.EqualsAny) + { + var currentValue = item.GetOption(qualifier, "rotation"); + if (!string.IsNullOrEmpty(currentValue) && values.Any(v => string.Equals(v, currentValue, StringComparison.OrdinalIgnoreCase))) + { + item.SetOption(qualifier, "rotation", currentValue); + } + else + { + item.SetOption(qualifier, "rotation", string.Join(',', values)); + } + } + + break; + } + case ProfileConditionValue.Height: { if (!enableNonQualifiedConditions) @@ -2281,6 +2350,7 @@ namespace MediaBrowser.Model.Dlna bool? isInterlaced = videoStream?.IsInterlaced; string? videoCodecTag = videoStream?.CodecTag; bool? isAvc = videoStream?.IsAVC; + int? videoRotation = videoStream?.Rotation; TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : mediaSource.Timestamp; int? packetLength = videoStream?.PacketLength; @@ -2290,7 +2360,7 @@ namespace MediaBrowser.Model.Dlna int? numAudioStreams = mediaSource.GetStreamCount(MediaStreamType.Audio); int? numVideoStreams = mediaSource.GetStreamCount(MediaStreamType.Video); - return conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)); + return conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc, videoRotation)); } /// <summary> 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/Drawing/ImageDimensions.cs b/MediaBrowser.Model/Drawing/ImageDimensions.cs index f84fe68305..49528ef8ae 100644 --- a/MediaBrowser.Model/Drawing/ImageDimensions.cs +++ b/MediaBrowser.Model/Drawing/ImageDimensions.cs @@ -1,4 +1,5 @@ #pragma warning disable CS1591 +#pragma warning disable CA1815 using System.Globalization; diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index e96bba0464..062034327e 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -800,5 +800,7 @@ namespace MediaBrowser.Model.Dto /// </summary> /// <value>The current program.</value> public BaseItemDto CurrentProgram { get; set; } + + public string OriginalLanguage { get; set; } } } 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 4491fb5ace..f057714bea 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -260,6 +260,8 @@ namespace MediaBrowser.Model.Entities public string LocalizedLanguage { get; set; } + public string LocalizedOriginal { get; set; } + public string DisplayTitle { get @@ -267,162 +269,167 @@ namespace MediaBrowser.Model.Entities switch (Type) { case MediaStreamType.Audio: - { - var attributes = new List<string>(); - - // Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded). - if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase)) { - // Use pre-resolved localized language name, falling back to raw language code. - attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language)); - } + var attributes = new List<string>(); - if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase)) - { - attributes.Add(Profile); - } - else if (!string.IsNullOrEmpty(Codec)) - { - attributes.Add(AudioCodec.GetFriendlyName(Codec)); - } + // Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded). + if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase)) + { + // Use pre-resolved localized language name, falling back to raw language code. + attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language)); + } - if (!string.IsNullOrEmpty(ChannelLayout)) - { - attributes.Add(StringHelper.FirstToUpper(ChannelLayout)); - } - else if (Channels.HasValue) - { - attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch"); - } + if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase)) + { + attributes.Add(Profile); + } + else if (!string.IsNullOrEmpty(Codec)) + { + attributes.Add(AudioCodec.GetFriendlyName(Codec)); + } - if (IsDefault) - { - attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault); - } + if (!string.IsNullOrEmpty(ChannelLayout)) + { + attributes.Add(StringHelper.FirstToUpper(ChannelLayout)); + } + else if (Channels.HasValue) + { + attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch"); + } - if (IsExternal) - { - attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal); - } + if (IsDefault) + { + attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault); + } - if (!string.IsNullOrEmpty(Title)) - { - var result = new StringBuilder(Title); - foreach (var tag in attributes) + if (IsExternal) + { + attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal); + } + + if (IsOriginal) + { + attributes.Add(string.IsNullOrEmpty(LocalizedOriginal) ? "Original" : LocalizedOriginal); + } + + if (!string.IsNullOrEmpty(Title)) { - // Keep Tags that are not already in Title. - if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) + var result = new StringBuilder(Title); + foreach (var tag in attributes) { - result.Append(" - ").Append(tag); + // Keep Tags that are not already in Title. + if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) + { + result.Append(" - ").Append(tag); + } } + + return result.ToString(); } - return result.ToString(); + return string.Join(" - ", attributes); } - return string.Join(" - ", attributes); - } - case MediaStreamType.Video: - { - var attributes = new List<string>(); + { + var attributes = new List<string>(); - var resolutionText = GetResolutionText(); + var resolutionText = GetResolutionText(); - if (!string.IsNullOrEmpty(resolutionText)) - { - attributes.Add(resolutionText); - } + if (!string.IsNullOrEmpty(resolutionText)) + { + attributes.Add(resolutionText); + } - if (!string.IsNullOrEmpty(Codec)) - { - attributes.Add(Codec.ToUpperInvariant()); - } + if (!string.IsNullOrEmpty(Codec)) + { + attributes.Add(Codec.ToUpperInvariant()); + } - if (VideoDoViTitle is not null) - { - attributes.Add(VideoDoViTitle); - } - else if (VideoRange != VideoRange.Unknown) - { - attributes.Add(VideoRange.ToString()); - } + if (VideoDoViTitle is not null) + { + attributes.Add(VideoDoViTitle); + } + else if (VideoRange != VideoRange.Unknown) + { + attributes.Add(VideoRange.ToString()); + } - if (!string.IsNullOrEmpty(Title)) - { - var result = new StringBuilder(Title); - foreach (var tag in attributes) + if (!string.IsNullOrEmpty(Title)) { - // Keep Tags that are not already in Title. - if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) + var result = new StringBuilder(Title); + foreach (var tag in attributes) { - result.Append(" - ").Append(tag); + // Keep Tags that are not already in Title. + if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) + { + result.Append(" - ").Append(tag); + } } + + return result.ToString(); } - return result.ToString(); + return string.Join(' ', attributes); } - return string.Join(' ', attributes); - } - case MediaStreamType.Subtitle: - { - var attributes = new List<string>(); - - if (!string.IsNullOrEmpty(Language)) - { - // Use pre-resolved localized language name, falling back to raw language code. - attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language)); - } - else { - attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined); - } + var attributes = new List<string>(); - if (IsHearingImpaired == true) - { - attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired); - } + if (!string.IsNullOrEmpty(Language)) + { + // Use pre-resolved localized language name, falling back to raw language code. + attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language)); + } + else + { + attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined); + } - if (IsDefault) - { - attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault); - } + if (IsHearingImpaired == true) + { + attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired); + } - if (IsForced) - { - attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced); - } + if (IsDefault) + { + attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault); + } - if (!string.IsNullOrEmpty(Codec)) - { - attributes.Add(Codec.ToUpperInvariant()); - } + if (IsForced) + { + attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced); + } - if (IsExternal) - { - attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal); - } + if (!string.IsNullOrEmpty(Codec)) + { + attributes.Add(Codec.ToUpperInvariant()); + } - if (!string.IsNullOrEmpty(Title)) - { - var result = new StringBuilder(Title); - foreach (var tag in attributes) + if (IsExternal) { - // Keep Tags that are not already in Title. - if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) + attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal); + } + + if (!string.IsNullOrEmpty(Title)) + { + var result = new StringBuilder(Title); + foreach (var tag in attributes) { - result.Append(" - ").Append(tag); + // Keep Tags that are not already in Title. + if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase)) + { + result.Append(" - ").Append(tag); + } } + + return result.ToString(); } - return result.ToString(); + return string.Join(" - ", attributes); } - return string.Join(" - ", attributes); - } - default: return null; } @@ -500,6 +507,12 @@ namespace MediaBrowser.Model.Entities public bool IsHearingImpaired { get; set; } /// <summary> + /// Gets or sets a value indicating whether this instance is original. + /// </summary> + /// <value><c>true</c> if this instance is original; otherwise, <c>false</c>.</value> + public bool IsOriginal { get; set; } + + /// <summary> /// Gets or sets the height. /// </summary> /// <value>The height.</value> @@ -631,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]. @@ -715,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)); @@ -728,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/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index f6e65028e4..7ad240abfb 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -51,6 +51,15 @@ public interface ILocalizationManager string GetLocalizedString(string phrase); /// <summary> + /// Gets the localized string using the server's configured UICulture, + /// ignoring the current request's culture. Use this for data that is + /// persisted (e.g. activity log entries) rather than returned per-request. + /// </summary> + /// <param name="phrase">The phrase.</param> + /// <returns>System.String.</returns> + string GetServerLocalizedString(string phrase); + + /// <summary> /// Gets the localization options. /// </summary> /// <returns><see cref="IEnumerable{LocalizationOption}" />.</returns> diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index c655c4ccb3..2dddd39ef4 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Model</PackageId> - <VersionPrefix>10.12.0</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> 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/Querying/QueryFilters.cs b/MediaBrowser.Model/Querying/QueryFilters.cs index 73b27a7b06..095f460923 100644 --- a/MediaBrowser.Model/Querying/QueryFilters.cs +++ b/MediaBrowser.Model/Querying/QueryFilters.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using MediaBrowser.Model.Dto; namespace MediaBrowser.Model.Querying @@ -12,10 +13,16 @@ namespace MediaBrowser.Model.Querying { Tags = Array.Empty<string>(); Genres = Array.Empty<NameGuidPair>(); + AudioLanguages = Array.Empty<NameValuePair>(); + SubtitleLanguages = Array.Empty<NameValuePair>(); } - public NameGuidPair[] Genres { get; set; } + public IReadOnlyList<NameGuidPair> Genres { get; set; } - public string[] Tags { get; set; } + public IReadOnlyList<string> Tags { get; set; } + + public IReadOnlyList<NameValuePair> AudioLanguages { get; set; } + + public IReadOnlyList<NameValuePair> SubtitleLanguages { get; set; } } } 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/Session/TranscodeReason.cs b/MediaBrowser.Model/Session/TranscodeReason.cs index 902bab9a6e..4ea60f115a 100644 --- a/MediaBrowser.Model/Session/TranscodeReason.cs +++ b/MediaBrowser.Model/Session/TranscodeReason.cs @@ -24,6 +24,7 @@ namespace MediaBrowser.Model.Session VideoResolutionNotSupported = 1 << 8, VideoBitDepthNotSupported = 1 << 9, VideoFramerateNotSupported = 1 << 10, + VideoRotationNotSupported = 1 << 27, RefFramesNotSupported = 1 << 11, AnamorphicVideoNotSupported = 1 << 12, InterlacedVideoNotSupported = 1 << 13, 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..b4e3fd3089 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -125,7 +125,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; 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 abdfb1e3b7..60eda1628c 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; @@ -1023,6 +1038,11 @@ namespace MediaBrowser.Providers.Manager target.OriginalTitle = source.OriginalTitle; } + if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage)) + { + target.OriginalLanguage = source.OriginalLanguage; + } + if (replaceData || !target.CommunityRating.HasValue) { target.CommunityRating = source.CommunityRating; diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 65edcb2a92..73df6d03d2 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -1,17 +1,20 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Mime; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.BaseItemManager; @@ -64,6 +67,7 @@ namespace MediaBrowser.Providers.Manager private readonly PriorityQueue<(Guid ItemId, MetadataRefreshOptions RefreshOptions), RefreshPriority> _refreshQueue = new(); private readonly IMemoryCache _memoryCache; private readonly IMediaSegmentManager _mediaSegmentManager; + private readonly ISimilarItemsManager _similarItemsManager; private readonly AsyncKeyedLocker<string> _imageSaveLock = new(o => { o.PoolSize = 20; @@ -101,6 +105,7 @@ namespace MediaBrowser.Providers.Manager /// <param name="lyricManager">The lyric manager.</param> /// <param name="memoryCache">The memory cache.</param> /// <param name="mediaSegmentManager">The media segment manager.</param> + /// <param name="similarItemsManager">The similar items manager.</param> public ProviderManager( IHttpClientFactory httpClientFactory, ISubtitleManager subtitleManager, @@ -113,7 +118,8 @@ namespace MediaBrowser.Providers.Manager IBaseItemManager baseItemManager, ILyricManager lyricManager, IMemoryCache memoryCache, - IMediaSegmentManager mediaSegmentManager) + IMediaSegmentManager mediaSegmentManager, + ISimilarItemsManager similarItemsManager) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -127,6 +133,7 @@ namespace MediaBrowser.Providers.Manager _lyricManager = lyricManager; _memoryCache = memoryCache; _mediaSegmentManager = mediaSegmentManager; + _similarItemsManager = similarItemsManager; CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated; } @@ -687,6 +694,14 @@ namespace MediaBrowser.Providers.Manager Type = MetadataPluginType.MediaSegmentProvider })); + // Similar items providers + var similarItemsProviders = _similarItemsManager.GetSimilarItemsProviders<T>(); + pluginList.AddRange(similarItemsProviders.Select(i => new MetadataPlugin + { + Name = i.Name, + Type = i.Type + })); + summary.Plugins = pluginList.ToArray(); var supportedImageTypes = imageProviders.OfType<IRemoteImageProvider>() diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index ed0c63b97f..df51dd8421 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -22,6 +22,7 @@ <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Newtonsoft.Json" /> <PackageReference Include="PlaylistsNET" /> + <PackageReference Include="SharpCompress" /> <PackageReference Include="z440.atl.core" /> <PackageReference Include="TMDbLib" /> </ItemGroup> @@ -47,16 +48,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/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index 0716cdfa01..6f9d5f19da 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -218,12 +218,12 @@ namespace MediaBrowser.Providers.MediaInfo return Array.Empty<ExternalPathParserResult>(); } - var files = directoryService.GetFilePaths(folder, clearCache, true).ToList(); + var files = directoryService.GetFilePaths(folder, clearCache).ToList(); files.Remove(video.Path); var internalMetadataPath = video.GetInternalMetadataPath(); if (_fileSystem.DirectoryExists(internalMetadataPath)) { - files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true)); + files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache)); } if (files.Count == 0) @@ -270,12 +270,12 @@ namespace MediaBrowser.Providers.MediaInfo } string folder = audio.ContainingFolderPath; - var files = directoryService.GetFilePaths(folder, clearCache, true).ToList(); + var files = directoryService.GetFilePaths(folder, clearCache).ToList(); files.Remove(audio.Path); var internalMetadataPath = audio.GetInternalMetadataPath(); if (_fileSystem.DirectoryExists(internalMetadataPath)) { - files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true)); + files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache)); } if (files.Count == 0) 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/Movies/ImdbExternalUrlProvider.cs b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs index 980bac102e..67cb85de69 100644 --- a/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs +++ b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -17,6 +18,18 @@ public class ImdbExternalUrlProvider : IExternalUrlProvider public IEnumerable<string> GetExternalUrls(BaseItem item) { var baseUrl = "https://www.imdb.com/"; + + if (item is Season season) + { + if (season.Series?.TryGetProviderId(MetadataProvider.Imdb, out var seriesImdbId) == true + && season.IndexNumber.HasValue) + { + yield return baseUrl + $"title/{seriesImdbId}/episodes/?season={season.IndexNumber.Value}"; + } + + yield break; + } + if (item.TryGetProviderId(MetadataProvider.Imdb, out var externalId)) { if (item is Person) 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/Api/ListenBrainzLabsClient.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs new file mode 100644 index 0000000000..e080370b8c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models; +using MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api; + +/// <summary> +/// Client for the ListenBrainz Labs API. +/// </summary> +public class ListenBrainzLabsClient : IDisposable +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<ListenBrainzLabsClient> _logger; + private readonly SemaphoreSlim _rateLimitLock = new(1, 1); + + private DateTime _lastRequestTime = DateTime.MinValue; + + /// <summary> + /// Initializes a new instance of the <see cref="ListenBrainzLabsClient"/> class. + /// </summary> + /// <param name="httpClientFactory">The HTTP client factory.</param> + /// <param name="logger">The logger.</param> + public ListenBrainzLabsClient( + IHttpClientFactory httpClientFactory, + ILogger<ListenBrainzLabsClient> logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// <summary> + /// Gets similar artists for the given MusicBrainz artist ID. + /// </summary> + /// <param name="artistMbid">The MusicBrainz artist ID.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A list of similar artist MusicBrainz IDs ordered by similarity score.</returns> + public async Task<IReadOnlyList<Guid>> GetSimilarArtistsAsync( + Guid artistMbid, + CancellationToken cancellationToken) + { + var config = ListenBrainzPlugin.Instance?.Configuration; + var baseUrl = config?.LabsServer ?? PluginConfiguration.DefaultLabsServer; + var algorithm = config?.AlgorithmString ?? new PluginConfiguration().AlgorithmString; + var rateLimit = config?.RateLimit ?? PluginConfiguration.DefaultRateLimit; + + // Enforce rate limit + await EnforceRateLimitAsync(rateLimit, cancellationToken).ConfigureAwait(false); + + var url = $"{baseUrl}/similar-artists/json?artist_mbids={artistMbid}&algorithm={algorithm}"; + + _logger.LogDebug("Fetching similar artists from ListenBrainz Labs: {Url}", url); + + try + { + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + var response = await httpClient.GetFromJsonAsync<List<SimilarArtistData>>(url, cancellationToken).ConfigureAwait(false); + + if (response is null || response.Count == 0) + { + _logger.LogDebug("No similar artists found for {ArtistMbid}", artistMbid); + return []; + } + + var similarMbids = response + .Where(a => !a.ArtistMbid.Equals(artistMbid)) // Exclude the source artist + .OrderByDescending(a => a.Score) + .Select(a => a.ArtistMbid) + .ToList(); + + _logger.LogDebug("Found {Count} similar artists for {ArtistMbid}", similarMbids.Count, artistMbid); + + return similarMbids; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to fetch similar artists from ListenBrainz Labs for {ArtistMbid}", artistMbid); + return []; + } + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _rateLimitLock.Dispose(); + } + } + + private async Task EnforceRateLimitAsync(double rateLimitSeconds, CancellationToken cancellationToken) + { + await _rateLimitLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime; + var requiredDelay = TimeSpan.FromSeconds(rateLimitSeconds) - timeSinceLastRequest; + + if (requiredDelay > TimeSpan.Zero) + { + await Task.Delay(requiredDelay, cancellationToken).ConfigureAwait(false); + } + + _lastRequestTime = DateTime.UtcNow; + } + finally + { + _rateLimitLock.Release(); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs new file mode 100644 index 0000000000..237f33ee3a --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models; + +/// <summary> +/// A similar artist data entry from the ListenBrainz Labs API. +/// </summary> +public class SimilarArtistData +{ + /// <summary> + /// Gets or sets the MusicBrainz artist ID. + /// </summary> + [JsonPropertyName("artist_mbid")] + public Guid ArtistMbid { get; set; } + + /// <summary> + /// Gets or sets the artist name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the similarity score. + /// </summary> + [JsonPropertyName("score")] + public double Score { get; set; } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs new file mode 100644 index 0000000000..12e8f25dcc --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models; + +/// <summary> +/// Response from ListenBrainz Labs similar-artists endpoint. +/// </summary> +public class SimilarArtistsResponse +{ + /// <summary> + /// Gets or sets the list of similar artists. + /// </summary> + [JsonPropertyName("data")] + public IReadOnlyList<SimilarArtistData>? Data { get; set; } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/ListenBrainz_logo.svg b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/ListenBrainz_logo.svg new file mode 100644 index 0000000000..416a097f9c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/ListenBrainz_logo.svg @@ -0,0 +1,60 @@ +<svg version="1.2" baseProfile="tiny-ps" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 450" width="800" height="450"> + <title>ListenBrainz_logo-svg</title> + <defs> + <image width="800" height="450" id="img1" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAyAAAAHCAQMAAAAtrT+LAAAAAXNSR0IB2cksfwAAAANQTFRF9tmZzxpnDgAAAENJREFUeJztwYEAAAAAw6D5U1/hAFUBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALwGsYoAAQbjkYoAAAAASUVORK5CYII="/> + <clipPath clipPathUnits="userSpaceOnUse" id="cp1"> + <path d="M173.35 178.24L224.79 178.24L224.79 269.03L173.35 269.03L173.35 178.24Z" /> + </clipPath> + <clipPath clipPathUnits="userSpaceOnUse" id="cp2"> + <path d="M173.35 178.24L224.79 178.24L224.79 269.03L173.35 269.03L173.35 178.24Z" /> + </clipPath> + <clipPath clipPathUnits="userSpaceOnUse" id="cp3"> + <path d="M173.35 178.24L224.79 178.24L224.79 269.03L173.35 269.03L173.35 178.24Z" /> + </clipPath> + </defs> + <style> + tspan { white-space:pre } + .shp0 { fill: #eb743b } + .shp1 { fill: #353070 } + .shp2 { fill: #000000 } + .shp3 { fill: #d3562c } + .shp4 { fill: #fffedb } + .shp5 { opacity: 0.251;fill: #000000 } + </style> + <use id="Background" href="#img1" x="0" y="0" /> + <path id="Layer" class="shp0" d="M173.35 152.82L173.35 296.82L234.35 261.82L234.35 187.82L173.35 152.82Z" /> + <path id="Layer" class="shp1" d="M168.35 152.82L107.35 187.82L107.35 261.82L168.35 296.82L168.35 152.82Z" /> + <g id="Layer" style="opacity: 0.071"> + <g id="Clip-Path" clip-path="url(#cp1)"> + <path id="Layer" class="shp2" d="M190.66 244.56C190.75 244.4 190.86 244.26 190.99 244.13C191.12 244 191.27 243.89 191.43 243.8C191.59 243.71 191.76 243.64 191.93 243.59C192.11 243.55 192.29 243.53 192.48 243.53C192.85 243.53 193.23 243.63 193.55 243.82C193.79 243.97 194 244.15 194.17 244.38C194.33 244.6 194.45 244.85 194.52 245.12C194.59 245.39 194.6 245.67 194.56 245.94C194.52 246.22 194.43 246.48 194.29 246.72C194.2 246.88 194.08 247.02 193.95 247.15C193.82 247.28 193.68 247.39 193.52 247.48C193.36 247.57 193.19 247.64 193.01 247.69C192.84 247.73 192.65 247.75 192.47 247.75C192.09 247.75 191.72 247.65 191.39 247.45C191.27 247.38 191.16 247.3 191.05 247.21C190.95 247.12 190.86 247.01 190.78 246.9C190.69 246.79 190.62 246.67 190.56 246.55C190.5 246.42 190.46 246.29 190.42 246.16C190.39 246.02 190.37 245.89 190.36 245.75C190.35 245.61 190.36 245.47 190.38 245.33C190.4 245.2 190.43 245.06 190.48 244.93C190.53 244.8 190.59 244.68 190.66 244.56M203.26 266.92C203.05 267.1 202.8 267.24 202.54 267.32C202.28 267.41 202 267.44 201.72 267.42C201.45 267.4 201.18 267.33 200.93 267.2C200.68 267.08 200.46 266.9 200.28 266.69C200.1 266.48 199.96 266.24 199.87 265.97C199.79 265.71 199.76 265.43 199.78 265.16C199.8 264.88 199.87 264.61 200 264.36C200.13 264.11 200.3 263.89 200.51 263.71C200.6 263.63 200.7 263.56 200.81 263.49C200.92 263.43 201.03 263.38 201.15 263.33C201.27 263.29 201.39 263.26 201.51 263.23C201.63 263.21 201.76 263.2 201.88 263.2C202.17 263.2 202.46 263.26 202.73 263.38C203 263.5 203.24 263.67 203.43 263.88C203.63 264.09 203.78 264.35 203.88 264.62C203.98 264.9 204.01 265.19 203.99 265.48C203.98 265.62 203.96 265.75 203.92 265.89C203.88 266.02 203.83 266.15 203.77 266.27C203.71 266.4 203.63 266.51 203.55 266.62C203.46 266.73 203.36 266.83 203.26 266.92M193.73 208.08C193.88 207.85 194.08 207.65 194.3 207.49C194.53 207.33 194.79 207.21 195.06 207.15C195.33 207.09 195.61 207.09 195.88 207.13C196.15 207.18 196.41 207.28 196.65 207.42C196.88 207.57 197.09 207.77 197.25 207.99C197.41 208.22 197.52 208.48 197.58 208.75C197.64 209.02 197.65 209.3 197.6 209.57C197.56 209.85 197.46 210.11 197.31 210.34C197.16 210.58 196.97 210.78 196.74 210.94C196.51 211.1 196.26 211.22 195.99 211.28C195.72 211.34 195.44 211.35 195.16 211.3C194.89 211.25 194.63 211.15 194.39 211C194.16 210.85 193.95 210.66 193.79 210.43C193.63 210.21 193.52 209.95 193.46 209.68C193.4 209.41 193.39 209.13 193.44 208.85C193.49 208.58 193.59 208.32 193.73 208.08ZM200.11 187.89C199.51 188.84 198.18 189.16 197.2 188.55L197.1 188.48C196.89 188.33 196.7 188.13 196.55 187.91C196.41 187.68 196.31 187.43 196.26 187.17C196.21 186.91 196.2 186.64 196.25 186.37C196.3 186.11 196.4 185.86 196.55 185.63C196.64 185.48 196.76 185.35 196.88 185.22C197.01 185.1 197.16 185 197.31 184.91C197.47 184.83 197.63 184.76 197.81 184.72C197.98 184.67 198.15 184.65 198.33 184.65C198.71 184.65 199.08 184.75 199.41 184.95C199.73 185.14 200 185.42 200.18 185.75C200.36 186.08 200.45 186.45 200.44 186.83C200.43 187.21 200.32 187.57 200.11 187.89M216.88 199.49C216.97 199.34 217.09 199.2 217.22 199.08C217.35 198.96 217.49 198.86 217.64 198.77C217.8 198.68 217.97 198.62 218.14 198.58C218.31 198.53 218.49 198.51 218.66 198.51C219.06 198.51 219.45 198.62 219.79 198.84C220.27 199.14 220.6 199.61 220.72 200.17C220.76 200.3 220.77 200.44 220.78 200.58C220.78 200.71 220.77 200.85 220.74 200.99C220.72 201.12 220.68 201.25 220.63 201.38C220.58 201.51 220.52 201.63 220.44 201.75C219.85 202.7 218.51 203.02 217.53 202.4L217.44 202.34C217.22 202.19 217.03 201.99 216.89 201.77C216.74 201.54 216.64 201.29 216.59 201.03C216.54 200.77 216.54 200.49 216.59 200.23C216.64 199.97 216.74 199.72 216.88 199.49M216.88 222.95C216.97 222.8 217.09 222.67 217.22 222.54C217.35 222.42 217.49 222.32 217.64 222.23C217.8 222.15 217.97 222.08 218.14 222.04C218.31 222 218.49 221.97 218.66 221.97C219.06 221.97 219.45 222.09 219.79 222.3C220.27 222.6 220.6 223.07 220.72 223.63C220.76 223.76 220.77 223.9 220.78 224.04C220.78 224.18 220.77 224.31 220.74 224.45C220.72 224.59 220.68 224.72 220.63 224.85C220.58 224.98 220.52 225.1 220.44 225.21C219.85 226.16 218.51 226.48 217.53 225.87L217.44 225.8C217.22 225.65 217.03 225.45 216.89 225.23C216.74 225 216.64 224.75 216.59 224.49C216.54 224.23 216.54 223.96 216.59 223.69C216.64 223.43 216.74 223.18 216.88 222.95M216.88 249.26C216.97 249.11 217.09 248.97 217.22 248.85C217.35 248.73 217.49 248.62 217.64 248.54C217.8 248.45 217.97 248.39 218.14 248.34C218.31 248.3 218.49 248.28 218.66 248.28C219.06 248.28 219.45 248.39 219.79 248.6C220.27 248.91 220.6 249.38 220.72 249.93C220.76 250.07 220.77 250.2 220.78 250.34C220.78 250.48 220.77 250.62 220.74 250.75C220.72 250.89 220.68 251.02 220.63 251.15C220.58 251.28 220.52 251.4 220.44 251.52C219.85 252.47 218.51 252.79 217.53 252.17L217.44 252.11C217.22 251.95 217.03 251.76 216.89 251.53C216.74 251.31 216.64 251.06 216.59 250.79C216.54 250.53 216.54 250.26 216.59 250C216.64 249.73 216.74 249.48 216.88 249.26" /> + </g> + <g id="Clip-Path" clip-path="url(#cp2)"> + <path id="Layer" fill-rule="evenodd" class="shp2" d="M206.53 258.93C206.79 259.23 207.02 259.56 207.22 259.91C207.42 260.26 207.58 260.63 207.71 261.01C207.83 261.39 207.92 261.78 207.96 262.18C208.01 262.58 208.02 262.98 207.99 263.38C207.96 263.78 207.89 264.17 207.78 264.56C207.67 264.95 207.53 265.32 207.35 265.68C207.17 266.04 206.95 266.38 206.7 266.69C206.45 267.01 206.17 267.3 205.87 267.56C205.59 267.79 205.3 268 204.99 268.19C204.68 268.37 204.35 268.52 204.01 268.65C203.67 268.78 203.32 268.87 202.96 268.94C202.61 269 202.25 269.03 201.89 269.03C201.49 269.03 201.1 268.99 200.71 268.92C200.32 268.84 199.94 268.73 199.57 268.58C199.21 268.43 198.85 268.24 198.52 268.02C198.19 267.81 197.88 267.56 197.6 267.28C195.75 267.98 192.69 269 190.58 269C190.48 269 190.37 269 190.27 269C188.3 268.89 187.03 268.03 186.01 267.34C184.44 266.28 182.95 265.27 177.6 266.63C175.24 267.71 173.63 267.79 173.36 267.82L173.36 263.82C173.86 263.72 174.73 263.55 175.91 263.01C175.94 263 175.97 262.98 176 262.97C176.03 262.95 176.06 262.94 176.09 262.92C176.12 262.91 176.15 262.89 176.18 262.88C176.21 262.87 176.24 262.86 176.27 262.85C179.62 261.24 184.79 257.38 188.84 248.16C188.56 247.95 188.29 247.72 188.05 247.47C187.8 247.22 187.58 246.94 187.39 246.65C187.19 246.36 187.02 246.05 186.88 245.73C186.74 245.41 186.62 245.07 186.54 244.73C186.44 244.34 186.38 243.95 186.36 243.55C186.33 243.15 186.35 242.74 186.41 242.35C186.47 241.95 186.57 241.56 186.7 241.18C186.84 240.8 187.01 240.44 187.22 240.1C187.49 239.64 187.82 239.23 188.19 238.86C188.57 238.49 188.99 238.17 189.45 237.91C189.91 237.65 190.4 237.45 190.91 237.32C191.42 237.18 191.95 237.11 192.48 237.11C192.75 237.11 193.02 237.13 193.3 237.17C193.57 237.2 193.84 237.26 194.1 237.33C194.37 237.4 194.62 237.5 194.88 237.6C195.13 237.71 195.37 237.83 195.61 237.97C198.51 239.71 199.46 243.47 197.73 246.37C197.47 246.8 197.16 247.21 196.79 247.56C196.43 247.92 196.03 248.23 195.59 248.49C195.15 248.75 194.68 248.95 194.2 249.1C193.71 249.24 193.2 249.32 192.69 249.34C190.12 255.34 187.05 259.41 184.11 262.18C185.84 262.51 187.09 263.23 188.26 264.02C189.18 264.65 189.66 264.95 190.48 264.99C191.55 265.03 193.86 264.36 195.81 263.66C195.75 263.16 195.75 262.66 195.81 262.16C195.87 261.66 196 261.17 196.18 260.7C196.36 260.23 196.6 259.79 196.89 259.38C197.18 258.97 197.52 258.59 197.9 258.26C198.51 257.74 199.22 257.34 199.98 257.09C200.74 256.84 201.55 256.74 202.35 256.8C203.15 256.87 203.94 257.08 204.65 257.45C205.37 257.81 206.01 258.32 206.53 258.93ZM190.66 242.15C190.59 242.27 190.53 242.4 190.48 242.53C190.43 242.66 190.4 242.79 190.38 242.93C190.36 243.07 190.35 243.2 190.36 243.34C190.37 243.48 190.39 243.62 190.42 243.75C190.46 243.89 190.5 244.02 190.56 244.14C190.62 244.27 190.69 244.39 190.78 244.5C190.86 244.61 190.95 244.71 191.05 244.8C191.16 244.89 191.27 244.98 191.39 245.05C191.72 245.24 192.09 245.35 192.47 245.35C192.65 245.35 192.84 245.33 193.01 245.28C193.19 245.23 193.36 245.16 193.52 245.07C193.68 244.98 193.82 244.87 193.95 244.75C194.08 244.62 194.2 244.47 194.29 244.32C194.58 243.83 194.66 243.26 194.52 242.71C194.38 242.17 194.04 241.7 193.56 241.42C193.07 241.13 192.5 241.05 191.95 241.18C191.41 241.32 190.94 241.67 190.66 242.15ZM203.26 264.51C203.36 264.42 203.46 264.32 203.55 264.22C203.63 264.11 203.71 263.99 203.77 263.87C203.83 263.74 203.88 263.62 203.92 263.48C203.96 263.35 203.98 263.21 203.99 263.07C204.01 262.78 203.98 262.49 203.88 262.22C203.78 261.94 203.63 261.69 203.43 261.47C203.24 261.26 203 261.09 202.73 260.97C202.46 260.86 202.17 260.8 201.88 260.8C201.76 260.8 201.63 260.81 201.51 260.83C201.39 260.85 201.27 260.88 201.15 260.93C201.03 260.97 200.92 261.02 200.81 261.09C200.7 261.15 200.6 261.23 200.51 261.31C200.3 261.49 200.13 261.71 200 261.96C199.87 262.2 199.8 262.47 199.78 262.75C199.76 263.03 199.79 263.31 199.87 263.57C199.96 263.83 200.1 264.08 200.28 264.29C200.46 264.5 200.68 264.67 200.93 264.8C201.18 264.92 201.45 265 201.72 265.02C202 265.04 202.28 265 202.54 264.92C202.8 264.83 203.05 264.69 203.26 264.51ZM204.3 183.03C204.39 183.42 204.44 183.82 204.45 184.22C204.46 184.62 204.43 185.02 204.36 185.41C204.3 185.8 204.19 186.19 204.04 186.56C203.9 186.94 203.72 187.29 203.5 187.63C203.23 188.07 202.9 188.47 202.52 188.82C202.15 189.17 201.73 189.48 201.28 189.72C200.83 189.97 200.35 190.16 199.85 190.29C199.36 190.42 198.84 190.48 198.33 190.48C198.18 190.48 198.03 190.47 197.88 190.46C197.73 190.45 197.58 190.43 197.43 190.41C197.29 190.39 197.14 190.36 196.99 190.33C196.85 190.29 196.7 190.26 196.56 190.21C194.18 193.04 191.3 194.53 188.78 195.31C190.8 196.86 192.59 198.72 194.12 200.88C195.7 200.52 197.41 200.76 198.79 201.63C201.65 203.44 202.5 207.23 200.7 210.08C200.42 210.52 200.09 210.92 199.72 211.27C199.34 211.63 198.93 211.93 198.47 212.18C198.02 212.43 197.54 212.62 197.04 212.74C196.54 212.87 196.03 212.94 195.52 212.93C194.36 212.93 193.23 212.61 192.25 211.98C189.4 210.18 188.55 206.39 190.34 203.54C190.37 203.49 190.4 203.45 190.43 203.4C190.46 203.36 190.49 203.31 190.53 203.27C190.56 203.22 190.59 203.18 190.62 203.13C190.66 203.09 190.69 203.04 190.72 203C188.45 199.89 185.56 197.52 182.12 195.96C182.11 195.96 182.11 195.96 182.1 195.96C178.91 194.5 175.8 193.96 173.35 193.82L173.35 189.82C176.13 189.98 179.67 190.54 183.36 192.14C184.59 192.16 189.66 191.97 193.3 187.85C192.96 187.36 192.69 186.82 192.51 186.25C192.32 185.68 192.22 185.09 192.21 184.49C192.2 183.89 192.27 183.3 192.43 182.72C192.59 182.14 192.84 181.59 193.16 181.09C193.43 180.65 193.76 180.25 194.13 179.9C194.51 179.55 194.92 179.24 195.38 178.99C195.83 178.74 196.31 178.55 196.81 178.43C197.3 178.3 197.82 178.24 198.33 178.24C198.62 178.24 198.91 178.26 199.19 178.3C199.47 178.34 199.76 178.4 200.03 178.48C200.31 178.56 200.58 178.66 200.84 178.78C201.1 178.89 201.35 179.03 201.6 179.18C201.94 179.4 202.26 179.64 202.55 179.92C202.84 180.2 203.1 180.5 203.34 180.83C203.57 181.16 203.77 181.51 203.93 181.88C204.09 182.25 204.22 182.63 204.3 183.03ZM193.73 205.67C193.59 205.91 193.49 206.17 193.44 206.45C193.39 206.72 193.4 207 193.46 207.27C193.52 207.54 193.63 207.8 193.79 208.03C193.95 208.25 194.16 208.45 194.39 208.6C194.63 208.75 194.89 208.85 195.16 208.89C195.44 208.94 195.72 208.93 195.99 208.87C196.26 208.81 196.51 208.7 196.74 208.54C196.97 208.38 197.16 208.17 197.31 207.94C197.46 207.7 197.56 207.44 197.6 207.17C197.65 206.89 197.64 206.61 197.58 206.34C197.52 206.07 197.41 205.81 197.25 205.59C197.09 205.36 196.88 205.17 196.65 205.02C196.41 204.87 196.15 204.77 195.88 204.73C195.61 204.68 195.33 204.69 195.06 204.75C194.79 204.81 194.53 204.92 194.3 205.08C194.08 205.24 193.88 205.44 193.73 205.67ZM200.11 185.49C200.32 185.17 200.43 184.8 200.44 184.42C200.45 184.05 200.36 183.67 200.18 183.34C200 183.01 199.73 182.73 199.41 182.54C199.08 182.35 198.71 182.25 198.33 182.25C198.15 182.25 197.98 182.27 197.81 182.31C197.63 182.35 197.47 182.42 197.31 182.51C197.16 182.59 197.01 182.7 196.88 182.82C196.76 182.94 196.64 183.08 196.55 183.23C196.4 183.45 196.3 183.7 196.25 183.97C196.2 184.23 196.21 184.5 196.26 184.76C196.31 185.03 196.41 185.28 196.55 185.5C196.7 185.73 196.89 185.92 197.11 186.08L197.2 186.14C198.18 186.76 199.51 186.44 200.11 185.49Z" /> + </g> + <g id="Clip-Path" clip-path="url(#cp3)"> + <path id="Layer" fill-rule="evenodd" class="shp2" d="M213 224.01C210.76 224.39 209.57 225.15 208.14 226.08C206.98 226.83 205.74 227.64 203.93 228.27C208.47 231.6 214.56 237.87 217.12 242.06C217.52 241.95 217.94 241.89 218.35 241.87C218.77 241.85 219.19 241.87 219.6 241.93C220.01 242 220.42 242.11 220.81 242.25C221.2 242.4 221.58 242.58 221.93 242.81C222.27 243.02 222.59 243.27 222.88 243.55C223.17 243.82 223.44 244.13 223.67 244.46C223.9 244.79 224.1 245.14 224.26 245.51C224.43 245.88 224.55 246.26 224.64 246.65C224.73 247.04 224.78 247.44 224.79 247.84C224.8 248.24 224.77 248.64 224.7 249.03C224.63 249.43 224.52 249.81 224.38 250.19C224.23 250.56 224.05 250.92 223.83 251.25C223.56 251.69 223.23 252.09 222.86 252.44C222.48 252.8 222.07 253.1 221.62 253.35C221.16 253.6 220.68 253.79 220.19 253.92C219.69 254.04 219.17 254.11 218.66 254.1C217.5 254.1 216.37 253.78 215.39 253.16C215.26 253.07 215.18 253.02 215.11 252.97C212.46 251.1 211.75 247.47 213.49 244.71C213.59 244.56 213.69 244.42 213.8 244.28C211.13 239.63 201.64 230.6 199.03 229.96C195.63 229.11 193.67 229.18 191.18 229.27C189.73 229.32 188.08 229.39 185.97 229.27C182.89 229.11 181.2 227.19 179.71 225.49C178.15 223.71 176.65 222.12 173.36 221.82L173.36 217.82C178.5 218.2 180.92 220.79 182.72 222.85C184.08 224.39 184.85 225.2 186.18 225.27C188.12 225.37 189.6 225.32 191.03 225.27C192.77 225.2 194.36 225.15 196.32 225.38C202 225.29 203.92 224.04 205.96 222.72C207.58 221.66 209.41 220.48 212.78 219.99C212.82 219.85 212.87 219.71 212.92 219.58C212.97 219.44 213.02 219.31 213.08 219.17C213.14 219.04 213.2 218.91 213.27 218.78C213.34 218.66 213.41 218.53 213.49 218.41C213.54 218.34 213.59 218.27 213.64 218.19C213.69 218.12 213.74 218.05 213.79 217.98C213.84 217.92 213.9 217.85 213.95 217.78C214.01 217.71 214.07 217.65 214.12 217.58C214.26 217.44 214.41 217.32 214.55 217.19C213.44 213.09 213.24 207.29 214.59 202.77C214.56 202.75 214.54 202.73 214.51 202.71C213.99 202.23 213.56 201.66 213.23 201.03C212.9 200.4 212.69 199.72 212.59 199.01C212.5 198.31 212.53 197.59 212.68 196.9C212.84 196.21 213.11 195.55 213.49 194.95C213.76 194.51 214.09 194.11 214.46 193.76C214.84 193.4 215.26 193.1 215.71 192.85C216.16 192.6 216.64 192.41 217.14 192.28C217.64 192.16 218.15 192.09 218.66 192.1C218.95 192.1 219.24 192.12 219.52 192.16C219.81 192.2 220.09 192.26 220.36 192.34C220.64 192.42 220.91 192.52 221.17 192.63C221.43 192.75 221.69 192.89 221.93 193.04C222.27 193.25 222.59 193.5 222.88 193.78C223.17 194.06 223.44 194.36 223.67 194.69C223.9 195.02 224.1 195.37 224.26 195.74C224.42 196.11 224.55 196.49 224.64 196.88C224.73 197.27 224.78 197.67 224.79 198.07C224.8 198.47 224.77 198.87 224.7 199.27C224.63 199.66 224.52 200.05 224.38 200.42C224.23 200.79 224.05 201.15 223.83 201.49C223.56 201.92 223.23 202.32 222.86 202.68C222.48 203.03 222.07 203.33 221.62 203.58C221.16 203.83 220.68 204.02 220.19 204.15C219.69 204.27 219.17 204.34 218.66 204.34C218.55 204.34 218.44 204.32 218.32 204.31C217.37 207.81 217.58 212.42 218.29 215.58C218.41 215.57 218.54 215.56 218.66 215.56C218.95 215.56 219.24 215.58 219.52 215.62C219.81 215.66 220.09 215.72 220.36 215.8C220.64 215.88 220.91 215.98 221.17 216.1C221.43 216.21 221.69 216.35 221.93 216.5C222.27 216.72 222.59 216.96 222.88 217.24C223.17 217.52 223.44 217.82 223.67 218.15C223.9 218.48 224.1 218.83 224.26 219.2C224.42 219.57 224.55 219.95 224.64 220.35C224.73 220.74 224.78 221.14 224.79 221.54C224.8 221.94 224.77 222.34 224.7 222.73C224.63 223.12 224.52 223.51 224.38 223.88C224.23 224.26 224.05 224.61 223.83 224.95C223.56 225.39 223.23 225.79 222.86 226.14C222.48 226.49 222.07 226.8 221.62 227.05C221.16 227.29 220.68 227.48 220.19 227.61C219.69 227.74 219.17 227.8 218.66 227.8C218.34 227.8 218.03 227.78 217.72 227.73C217.4 227.68 217.1 227.6 216.8 227.51C216.49 227.41 216.2 227.29 215.92 227.15C215.64 227.01 215.37 226.84 215.11 226.66C214.88 226.5 214.65 226.31 214.45 226.12C214.24 225.92 214.04 225.71 213.87 225.49C213.69 225.26 213.52 225.03 213.38 224.78C213.24 224.53 213.11 224.28 213 224.01ZM216.88 197.09C216.74 197.31 216.64 197.56 216.59 197.83C216.54 198.09 216.54 198.36 216.59 198.62C216.64 198.88 216.74 199.14 216.89 199.36C217.03 199.59 217.22 199.78 217.44 199.94L217.53 200C218.51 200.62 219.85 200.3 220.44 199.34C220.52 199.23 220.58 199.11 220.63 198.98C220.68 198.85 220.72 198.72 220.74 198.58C220.77 198.44 220.78 198.31 220.78 198.17C220.77 198.03 220.76 197.89 220.73 197.76C220.6 197.2 220.27 196.73 219.79 196.43C219.45 196.22 219.06 196.1 218.66 196.1C218.49 196.1 218.31 196.13 218.14 196.17C217.97 196.21 217.8 196.28 217.64 196.36C217.49 196.45 217.35 196.55 217.22 196.68C217.09 196.8 216.97 196.93 216.88 197.09ZM216.88 220.55C216.74 220.77 216.64 221.03 216.59 221.29C216.54 221.55 216.54 221.82 216.59 222.09C216.64 222.35 216.74 222.6 216.89 222.82C217.03 223.05 217.22 223.24 217.44 223.4L217.53 223.46C218.51 224.08 219.85 223.76 220.44 222.81C220.52 222.69 220.58 222.57 220.63 222.44C220.68 222.31 220.72 222.18 220.74 222.04C220.77 221.91 220.78 221.77 220.78 221.63C220.77 221.49 220.76 221.36 220.72 221.22C220.6 220.67 220.27 220.2 219.79 219.89C219.45 219.68 219.06 219.57 218.66 219.57C218.49 219.57 218.31 219.59 218.14 219.63C217.97 219.68 217.8 219.74 217.64 219.83C217.49 219.91 217.35 220.02 217.22 220.14C217.09 220.26 216.97 220.4 216.88 220.55ZM216.88 246.85C216.74 247.08 216.64 247.33 216.59 247.59C216.54 247.86 216.54 248.13 216.59 248.39C216.64 248.65 216.74 248.9 216.89 249.13C217.03 249.35 217.22 249.55 217.44 249.7L217.53 249.76C218.51 250.38 219.85 250.06 220.44 249.11C220.52 249 220.58 248.87 220.63 248.74C220.68 248.62 220.72 248.48 220.74 248.35C220.77 248.21 220.78 248.07 220.78 247.94C220.77 247.8 220.76 247.66 220.72 247.53C220.6 246.97 220.27 246.5 219.79 246.2C219.45 245.98 219.06 245.87 218.66 245.87C218.49 245.87 218.31 245.89 218.14 245.94C217.97 245.98 217.8 246.05 217.64 246.13C217.49 246.22 217.35 246.32 217.22 246.44C217.09 246.56 216.97 246.7 216.88 246.85Z" /> + </g> + </g> + <path id="Layer" fill-rule="evenodd" class="shp3" d="M206.53 261.93C206.79 262.23 207.02 262.56 207.22 262.91C207.42 263.26 207.58 263.63 207.71 264.01C207.83 264.39 207.92 264.78 207.96 265.18C208.01 265.58 208.02 265.98 207.99 266.38C207.96 266.78 207.89 267.17 207.78 267.56C207.67 267.95 207.53 268.32 207.35 268.68C207.17 269.04 206.95 269.38 206.7 269.69C206.45 270.01 206.17 270.3 205.87 270.56C205.59 270.79 205.3 271 204.99 271.19C204.68 271.37 204.35 271.52 204.01 271.65C203.67 271.78 203.32 271.87 202.96 271.94C202.61 272 202.25 272.03 201.89 272.03C201.49 272.03 201.1 271.99 200.71 271.92C200.32 271.84 199.94 271.73 199.57 271.58C199.21 271.43 198.85 271.24 198.52 271.02C198.19 270.81 197.88 270.56 197.6 270.28C195.75 270.98 192.69 272 190.58 272C190.48 272 190.37 272 190.27 272C188.3 271.89 187.03 271.03 186.01 270.34C184.44 269.28 182.95 268.27 177.6 269.63C175.24 270.71 173.63 270.79 173.36 270.82L173.36 266.82C173.86 266.72 174.73 266.55 175.91 266.01C175.94 266 175.97 265.98 176 265.97C176.03 265.95 176.06 265.94 176.09 265.92C176.12 265.91 176.15 265.89 176.18 265.88C176.21 265.87 176.24 265.86 176.27 265.85C179.62 264.24 184.79 260.38 188.84 251.16C188.56 250.95 188.29 250.72 188.05 250.47C187.8 250.22 187.58 249.94 187.39 249.65C187.19 249.36 187.02 249.05 186.88 248.73C186.74 248.41 186.62 248.07 186.54 247.73C186.44 247.34 186.38 246.95 186.36 246.55C186.33 246.15 186.35 245.74 186.41 245.35C186.47 244.95 186.57 244.56 186.7 244.18C186.84 243.8 187.01 243.44 187.22 243.1C187.49 242.64 187.82 242.23 188.19 241.86C188.57 241.49 188.99 241.17 189.45 240.91C189.91 240.65 190.4 240.45 190.91 240.32C191.42 240.18 191.95 240.11 192.48 240.11C192.75 240.11 193.02 240.13 193.3 240.17C193.57 240.2 193.84 240.26 194.1 240.33C194.37 240.4 194.62 240.5 194.88 240.6C195.13 240.71 195.37 240.83 195.61 240.97C198.51 242.71 199.46 246.47 197.73 249.37C197.47 249.8 197.16 250.21 196.79 250.56C196.43 250.92 196.03 251.23 195.59 251.49C195.15 251.75 194.68 251.95 194.2 252.1C193.71 252.24 193.2 252.32 192.69 252.34C190.12 258.35 187.05 262.41 184.11 265.18C185.84 265.51 187.09 266.23 188.26 267.02C189.18 267.65 189.66 267.95 190.48 267.99C191.55 268.03 193.86 267.36 195.81 266.66C195.75 266.16 195.75 265.66 195.81 265.16C195.87 264.66 196 264.17 196.18 263.7C196.36 263.23 196.6 262.79 196.89 262.38C197.18 261.97 197.52 261.59 197.9 261.26C198.51 260.74 199.22 260.34 199.98 260.09C200.74 259.84 201.55 259.74 202.35 259.8C203.15 259.87 203.94 260.08 204.65 260.45C205.37 260.81 206.01 261.32 206.53 261.93ZM190.66 245.15C190.59 245.27 190.53 245.4 190.48 245.53C190.43 245.66 190.4 245.79 190.38 245.93C190.36 246.07 190.35 246.2 190.36 246.34C190.37 246.48 190.39 246.62 190.42 246.75C190.46 246.89 190.5 247.02 190.56 247.14C190.62 247.27 190.69 247.39 190.78 247.5C190.86 247.61 190.95 247.71 191.05 247.8C191.16 247.89 191.27 247.98 191.39 248.05C191.72 248.24 192.09 248.35 192.47 248.35C192.65 248.35 192.84 248.33 193.01 248.28C193.19 248.23 193.36 248.16 193.52 248.07C193.68 247.98 193.82 247.87 193.95 247.75C194.08 247.62 194.2 247.47 194.29 247.32C194.58 246.83 194.66 246.26 194.52 245.71C194.38 245.17 194.04 244.7 193.56 244.42C193.07 244.13 192.5 244.05 191.95 244.18C191.41 244.32 190.94 244.67 190.66 245.15ZM203.26 267.51C203.36 267.42 203.46 267.32 203.55 267.22C203.63 267.11 203.71 266.99 203.77 266.87C203.83 266.74 203.88 266.62 203.92 266.48C203.96 266.35 203.98 266.21 203.99 266.07C204.01 265.78 203.98 265.49 203.88 265.22C203.78 264.94 203.63 264.69 203.43 264.47C203.24 264.26 203 264.09 202.73 263.97C202.46 263.86 202.17 263.8 201.88 263.8C201.76 263.8 201.63 263.81 201.51 263.83C201.39 263.85 201.27 263.88 201.15 263.93C201.03 263.97 200.92 264.02 200.81 264.09C200.7 264.15 200.6 264.23 200.51 264.31C200.3 264.49 200.13 264.71 200 264.96C199.87 265.2 199.8 265.47 199.78 265.75C199.76 266.03 199.79 266.31 199.87 266.57C199.96 266.83 200.1 267.08 200.28 267.29C200.46 267.5 200.68 267.67 200.93 267.8C201.18 267.92 201.45 268 201.72 268.02C202 268.04 202.28 268 202.54 267.92C202.8 267.83 203.05 267.69 203.26 267.51Z" /> + <path id="Layer" class="shp2" d="" /> + <path id="Layer" fill-rule="evenodd" class="shp3" d="M204.3 185.03C204.39 185.42 204.44 185.82 204.45 186.22C204.46 186.62 204.43 187.02 204.36 187.41C204.3 187.81 204.19 188.19 204.04 188.57C203.9 188.94 203.72 189.3 203.5 189.63C203.23 190.07 202.9 190.47 202.53 190.82C202.15 191.18 201.73 191.48 201.28 191.73C200.83 191.98 200.35 192.17 199.85 192.29C199.36 192.42 198.84 192.48 198.33 192.48C198.18 192.48 198.03 192.48 197.88 192.46C197.73 192.45 197.58 192.44 197.43 192.41C197.29 192.39 197.14 192.36 196.99 192.33C196.85 192.3 196.7 192.26 196.56 192.22C194.18 195.04 191.3 196.53 188.78 197.31C190.8 198.86 192.59 200.72 194.12 202.88C195.7 202.52 197.41 202.76 198.79 203.63C201.65 205.44 202.5 209.24 200.7 212.09C200.42 212.52 200.09 212.92 199.72 213.28C199.34 213.63 198.93 213.93 198.47 214.18C198.02 214.43 197.54 214.62 197.04 214.75C196.54 214.88 196.03 214.94 195.52 214.94C194.36 214.94 193.23 214.61 192.25 213.99C189.4 212.19 188.55 208.4 190.34 205.54C190.37 205.5 190.4 205.45 190.43 205.4C190.46 205.36 190.49 205.31 190.53 205.27C190.56 205.22 190.59 205.18 190.62 205.14C190.66 205.09 190.69 205.05 190.72 205C188.45 201.89 185.56 199.53 182.12 197.96C182.11 197.96 182.11 197.96 182.1 197.96C178.91 196.51 175.8 195.97 173.35 195.82L173.35 191.83C176.13 191.98 179.67 192.54 183.36 194.14C184.59 194.16 189.66 193.97 193.3 189.85C192.96 189.36 192.69 188.82 192.51 188.25C192.32 187.68 192.22 187.09 192.21 186.49C192.2 185.9 192.27 185.3 192.43 184.72C192.59 184.15 192.84 183.6 193.16 183.09C193.43 182.66 193.76 182.26 194.13 181.9C194.51 181.55 194.92 181.25 195.38 181C195.83 180.75 196.31 180.56 196.81 180.43C197.3 180.3 197.82 180.24 198.33 180.24C198.62 180.24 198.91 180.26 199.19 180.3C199.47 180.34 199.76 180.4 200.03 180.48C200.31 180.56 200.58 180.66 200.84 180.78C201.1 180.9 201.35 181.03 201.6 181.19C201.94 181.4 202.26 181.65 202.55 181.93C202.84 182.2 203.1 182.51 203.34 182.84C203.57 183.17 203.77 183.52 203.93 183.89C204.09 184.25 204.22 184.64 204.3 185.03ZM193.73 207.68C193.59 207.91 193.49 208.18 193.44 208.45C193.39 208.72 193.4 209 193.46 209.28C193.52 209.55 193.63 209.8 193.79 210.03C193.95 210.26 194.16 210.45 194.39 210.6C194.63 210.75 194.89 210.85 195.16 210.9C195.44 210.95 195.72 210.94 195.99 210.88C196.26 210.82 196.51 210.7 196.74 210.54C196.97 210.38 197.16 210.18 197.31 209.94C197.46 209.71 197.56 209.44 197.6 209.17C197.65 208.9 197.64 208.62 197.58 208.35C197.52 208.07 197.41 207.82 197.25 207.59C197.09 207.36 196.88 207.17 196.65 207.02C196.41 206.88 196.15 206.78 195.88 206.73C195.61 206.68 195.33 206.69 195.06 206.75C194.79 206.81 194.53 206.93 194.3 207.08C194.08 207.24 193.88 207.44 193.73 207.68ZM200.11 187.49C200.32 187.17 200.43 186.8 200.44 186.43C200.45 186.05 200.36 185.68 200.18 185.34C200 185.01 199.73 184.74 199.41 184.55C199.08 184.35 198.71 184.25 198.33 184.25C198.15 184.25 197.98 184.27 197.81 184.32C197.63 184.36 197.47 184.42 197.31 184.51C197.16 184.59 197.01 184.7 196.88 184.82C196.76 184.94 196.64 185.08 196.55 185.23C196.4 185.46 196.3 185.71 196.25 185.97C196.2 186.23 196.21 186.5 196.26 186.77C196.31 187.03 196.41 187.28 196.55 187.51C196.7 187.73 196.89 187.93 197.11 188.08L197.2 188.14C198.18 188.76 199.51 188.44 200.11 187.49Z" /> + <path id="Layer" fill-rule="evenodd" class="shp3" d="M213 227.01C210.76 227.39 209.57 228.15 208.14 229.08C206.98 229.83 205.74 230.64 203.93 231.26C208.47 234.6 214.56 240.87 217.12 245.06C217.52 244.95 217.94 244.89 218.35 244.87C218.77 244.85 219.19 244.87 219.6 244.93C220.01 245 220.42 245.1 220.81 245.25C221.2 245.4 221.58 245.58 221.93 245.81C222.27 246.02 222.59 246.27 222.88 246.55C223.17 246.82 223.44 247.13 223.67 247.46C223.9 247.79 224.1 248.14 224.26 248.51C224.43 248.87 224.55 249.26 224.64 249.65C224.73 250.04 224.78 250.44 224.79 250.84C224.8 251.24 224.77 251.64 224.7 252.03C224.63 252.43 224.52 252.81 224.38 253.19C224.23 253.56 224.05 253.92 223.83 254.25C223.56 254.69 223.23 255.09 222.86 255.44C222.48 255.8 222.07 256.1 221.62 256.35C221.16 256.6 220.68 256.79 220.19 256.91C219.69 257.04 219.17 257.1 218.66 257.1C217.5 257.1 216.37 256.78 215.39 256.15C215.26 256.07 215.18 256.02 215.11 255.96C212.46 254.1 211.75 250.47 213.49 247.71C213.59 247.56 213.69 247.42 213.8 247.28C211.13 242.63 201.64 233.6 199.03 232.96C195.63 232.11 193.67 232.18 191.18 232.27C189.73 232.32 188.08 232.39 185.97 232.27C182.89 232.1 181.2 230.18 179.71 228.49C178.15 226.71 176.65 225.12 173.36 224.82L173.36 220.82C178.5 221.2 180.92 223.79 182.72 225.84C184.08 227.39 184.85 228.2 186.18 228.27C188.12 228.37 189.6 228.32 191.03 228.27C192.77 228.2 194.36 228.15 196.32 228.38C202 228.29 203.92 227.04 205.96 225.72C207.58 224.66 209.41 223.48 212.78 222.99C212.82 222.85 212.87 222.71 212.92 222.58C212.97 222.44 213.02 222.31 213.08 222.17C213.14 222.04 213.2 221.91 213.27 221.78C213.34 221.66 213.41 221.53 213.49 221.41C213.54 221.34 213.59 221.27 213.64 221.19C213.69 221.12 213.74 221.05 213.79 220.98C213.84 220.92 213.9 220.85 213.95 220.78C214.01 220.71 214.07 220.65 214.12 220.58C214.26 220.44 214.41 220.32 214.55 220.19C213.44 216.09 213.24 210.29 214.59 205.77C214.56 205.75 214.54 205.73 214.51 205.71C213.99 205.23 213.56 204.66 213.23 204.03C212.9 203.4 212.69 202.72 212.59 202.01C212.5 201.31 212.53 200.59 212.68 199.9C212.84 199.21 213.11 198.55 213.49 197.95C213.76 197.51 214.09 197.11 214.46 196.76C214.84 196.4 215.26 196.1 215.71 195.85C216.16 195.6 216.64 195.41 217.14 195.28C217.64 195.16 218.15 195.09 218.66 195.1C218.95 195.1 219.24 195.12 219.52 195.16C219.81 195.2 220.09 195.26 220.36 195.34C220.64 195.42 220.91 195.52 221.17 195.63C221.43 195.75 221.69 195.89 221.93 196.04C222.27 196.25 222.59 196.5 222.88 196.78C223.17 197.06 223.44 197.36 223.67 197.69C223.9 198.02 224.1 198.37 224.26 198.74C224.42 199.11 224.55 199.49 224.64 199.88C224.73 200.27 224.78 200.67 224.79 201.07C224.8 201.47 224.77 201.87 224.7 202.27C224.63 202.66 224.52 203.05 224.38 203.42C224.23 203.79 224.05 204.15 223.83 204.49C223.56 204.92 223.23 205.32 222.86 205.68C222.48 206.03 222.07 206.33 221.62 206.58C221.16 206.83 220.68 207.02 220.19 207.15C219.69 207.27 219.17 207.34 218.66 207.34C218.55 207.34 218.44 207.32 218.32 207.31C217.37 210.81 217.58 215.42 218.29 218.58C218.41 218.57 218.54 218.56 218.66 218.56C218.95 218.56 219.24 218.58 219.52 218.62C219.81 218.66 220.09 218.72 220.36 218.8C220.64 218.88 220.91 218.98 221.17 219.1C221.43 219.21 221.69 219.35 221.93 219.5C222.27 219.72 222.59 219.96 222.88 220.24C223.17 220.52 223.44 220.82 223.67 221.15C223.9 221.48 224.1 221.83 224.26 222.2C224.42 222.57 224.55 222.95 224.64 223.35C224.73 223.74 224.78 224.14 224.79 224.54C224.8 224.94 224.77 225.34 224.7 225.73C224.63 226.12 224.52 226.51 224.38 226.88C224.23 227.26 224.05 227.61 223.83 227.95C223.56 228.39 223.23 228.79 222.86 229.14C222.48 229.49 222.07 229.8 221.62 230.05C221.16 230.29 220.68 230.48 220.19 230.61C219.69 230.74 219.17 230.8 218.66 230.8C218.34 230.8 218.03 230.78 217.72 230.73C217.4 230.68 217.1 230.6 216.8 230.51C216.49 230.41 216.2 230.29 215.92 230.15C215.64 230.01 215.37 229.84 215.11 229.66C214.88 229.5 214.65 229.31 214.45 229.12C214.24 228.92 214.04 228.71 213.87 228.49C213.69 228.26 213.52 228.03 213.38 227.78C213.24 227.53 213.11 227.28 213 227.01ZM216.88 200.09C216.74 200.31 216.64 200.56 216.59 200.83C216.54 201.09 216.54 201.36 216.59 201.62C216.64 201.88 216.74 202.14 216.89 202.36C217.03 202.59 217.22 202.78 217.44 202.94L217.53 203C218.51 203.62 219.85 203.3 220.44 202.34C220.52 202.23 220.58 202.11 220.63 201.98C220.68 201.85 220.72 201.72 220.74 201.58C220.77 201.44 220.78 201.31 220.78 201.17C220.77 201.03 220.76 200.89 220.73 200.76C220.6 200.2 220.27 199.73 219.79 199.43C219.45 199.22 219.06 199.1 218.66 199.1C218.49 199.1 218.31 199.13 218.14 199.17C217.97 199.21 217.8 199.28 217.64 199.36C217.49 199.45 217.35 199.55 217.22 199.68C217.09 199.8 216.97 199.93 216.88 200.09ZM216.88 223.55C216.74 223.77 216.64 224.03 216.59 224.29C216.54 224.55 216.54 224.82 216.59 225.09C216.64 225.35 216.74 225.6 216.89 225.82C217.03 226.05 217.22 226.24 217.44 226.4L217.53 226.46C218.51 227.08 219.85 226.76 220.44 225.81C220.52 225.69 220.58 225.57 220.63 225.44C220.68 225.31 220.72 225.18 220.74 225.04C220.77 224.91 220.78 224.77 220.78 224.63C220.77 224.49 220.76 224.36 220.72 224.22C220.6 223.67 220.27 223.2 219.79 222.89C219.45 222.68 219.06 222.57 218.66 222.57C218.49 222.57 218.31 222.59 218.14 222.63C217.97 222.68 217.8 222.74 217.64 222.83C217.49 222.91 217.35 223.02 217.22 223.14C217.09 223.26 216.97 223.4 216.88 223.55ZM216.88 249.85C216.74 250.08 216.64 250.33 216.59 250.59C216.54 250.86 216.54 251.13 216.59 251.39C216.64 251.65 216.74 251.9 216.89 252.13C217.03 252.35 217.22 252.55 217.44 252.7L217.53 252.76C218.51 253.38 219.85 253.06 220.44 252.11C220.52 252 220.58 251.87 220.63 251.74C220.68 251.62 220.72 251.48 220.74 251.35C220.77 251.21 220.78 251.07 220.78 250.94C220.77 250.8 220.76 250.66 220.72 250.53C220.6 249.97 220.27 249.5 219.79 249.2C219.45 248.98 219.06 248.87 218.66 248.87C218.49 248.87 218.31 248.89 218.14 248.94C217.97 248.98 217.8 249.05 217.64 249.13C217.49 249.22 217.35 249.32 217.22 249.44C217.09 249.56 216.97 249.7 216.88 249.85Z" /> + <path id="Layer" fill-rule="evenodd" class="shp4" d="M206.53 258.93C206.79 259.23 207.02 259.56 207.22 259.91C207.42 260.26 207.58 260.63 207.71 261.01C207.83 261.39 207.92 261.78 207.96 262.18C208.01 262.58 208.02 262.98 207.99 263.38C207.96 263.78 207.89 264.17 207.78 264.56C207.67 264.95 207.53 265.32 207.35 265.68C207.17 266.04 206.95 266.38 206.7 266.69C206.45 267.01 206.17 267.3 205.87 267.56C205.59 267.79 205.3 268 204.99 268.19C204.68 268.37 204.35 268.52 204.01 268.65C203.67 268.78 203.32 268.87 202.96 268.94C202.61 269 202.25 269.03 201.89 269.03C201.49 269.03 201.1 268.99 200.71 268.92C200.32 268.84 199.94 268.73 199.57 268.58C199.21 268.43 198.85 268.24 198.52 268.02C198.19 267.81 197.88 267.56 197.6 267.28C195.75 267.98 192.69 269 190.58 269C190.48 269 190.37 269 190.27 269C188.3 268.89 187.03 268.03 186.01 267.34C184.44 266.28 182.95 265.27 177.6 266.63C175.24 267.71 173.63 267.79 173.36 267.82L173.36 263.82C173.86 263.72 174.73 263.55 175.91 263.01C175.94 263 175.97 262.98 176 262.97C176.03 262.95 176.06 262.94 176.09 262.92C176.12 262.91 176.15 262.89 176.18 262.88C176.21 262.87 176.24 262.86 176.27 262.85C179.62 261.24 184.79 257.38 188.84 248.16C188.56 247.95 188.29 247.72 188.05 247.47C187.8 247.22 187.58 246.94 187.39 246.65C187.19 246.36 187.02 246.05 186.88 245.73C186.74 245.41 186.62 245.07 186.54 244.73C186.44 244.34 186.38 243.95 186.36 243.55C186.33 243.15 186.35 242.74 186.41 242.35C186.47 241.95 186.57 241.56 186.7 241.18C186.84 240.8 187.01 240.44 187.22 240.1C187.49 239.64 187.82 239.23 188.19 238.86C188.57 238.49 188.99 238.17 189.45 237.91C189.91 237.65 190.4 237.45 190.91 237.32C191.42 237.18 191.95 237.11 192.48 237.11C192.75 237.11 193.02 237.13 193.3 237.17C193.57 237.2 193.84 237.26 194.1 237.33C194.37 237.4 194.62 237.5 194.88 237.6C195.13 237.71 195.37 237.83 195.61 237.97C198.51 239.71 199.46 243.47 197.73 246.37C197.47 246.8 197.16 247.21 196.79 247.56C196.43 247.92 196.03 248.23 195.59 248.49C195.15 248.75 194.68 248.95 194.2 249.1C193.71 249.24 193.2 249.32 192.69 249.34C190.12 255.34 187.05 259.41 184.11 262.18C185.84 262.51 187.09 263.23 188.26 264.02C189.18 264.65 189.66 264.95 190.48 264.99C191.55 265.03 193.86 264.36 195.81 263.66C195.75 263.16 195.75 262.66 195.81 262.16C195.87 261.66 196 261.17 196.18 260.7C196.36 260.23 196.6 259.79 196.89 259.38C197.18 258.97 197.52 258.59 197.9 258.26C198.51 257.74 199.22 257.34 199.98 257.09C200.74 256.84 201.55 256.74 202.35 256.8C203.15 256.87 203.94 257.08 204.65 257.45C205.37 257.81 206.01 258.32 206.53 258.93ZM190.66 242.15C190.59 242.27 190.53 242.4 190.48 242.53C190.43 242.66 190.4 242.79 190.38 242.93C190.36 243.07 190.35 243.2 190.36 243.34C190.37 243.48 190.39 243.62 190.42 243.75C190.46 243.89 190.5 244.02 190.56 244.14C190.62 244.27 190.69 244.39 190.78 244.5C190.86 244.61 190.95 244.71 191.05 244.8C191.16 244.89 191.27 244.98 191.39 245.05C191.72 245.24 192.09 245.35 192.47 245.35C192.65 245.35 192.84 245.33 193.01 245.28C193.19 245.23 193.36 245.16 193.52 245.07C193.68 244.98 193.82 244.87 193.95 244.75C194.08 244.62 194.2 244.47 194.29 244.32C194.58 243.83 194.66 243.26 194.52 242.71C194.38 242.17 194.04 241.7 193.56 241.42C193.07 241.13 192.5 241.05 191.95 241.18C191.41 241.32 190.94 241.67 190.66 242.15ZM203.26 264.51C203.36 264.42 203.46 264.32 203.55 264.22C203.63 264.11 203.71 263.99 203.77 263.87C203.83 263.74 203.88 263.62 203.92 263.48C203.96 263.35 203.98 263.21 203.99 263.07C204.01 262.78 203.98 262.49 203.88 262.22C203.78 261.94 203.63 261.69 203.43 261.47C203.24 261.26 203 261.09 202.73 260.97C202.46 260.86 202.17 260.8 201.88 260.8C201.76 260.8 201.63 260.81 201.51 260.83C201.39 260.85 201.27 260.88 201.15 260.93C201.03 260.97 200.92 261.02 200.81 261.09C200.7 261.15 200.6 261.23 200.51 261.31C200.3 261.49 200.13 261.71 200 261.96C199.87 262.2 199.8 262.47 199.78 262.75C199.76 263.03 199.79 263.31 199.87 263.57C199.96 263.83 200.1 264.08 200.28 264.29C200.46 264.5 200.68 264.67 200.93 264.8C201.18 264.92 201.45 265 201.72 265.02C202 265.04 202.28 265 202.54 264.92C202.8 264.83 203.05 264.69 203.26 264.51ZM204.3 183.03C204.39 183.42 204.44 183.82 204.45 184.22C204.46 184.62 204.43 185.02 204.36 185.41C204.3 185.8 204.19 186.19 204.04 186.56C203.9 186.94 203.72 187.29 203.5 187.63C203.23 188.07 202.9 188.47 202.52 188.82C202.15 189.17 201.73 189.48 201.28 189.72C200.83 189.97 200.35 190.16 199.85 190.29C199.36 190.42 198.84 190.48 198.33 190.48C198.18 190.48 198.03 190.47 197.88 190.46C197.73 190.45 197.58 190.43 197.43 190.41C197.29 190.39 197.14 190.36 196.99 190.33C196.85 190.29 196.7 190.26 196.56 190.21C194.18 193.04 191.3 194.53 188.78 195.31C190.8 196.86 192.59 198.72 194.12 200.88C195.7 200.52 197.41 200.76 198.79 201.63C201.65 203.44 202.5 207.23 200.7 210.08C200.42 210.52 200.09 210.92 199.72 211.27C199.34 211.63 198.93 211.93 198.47 212.18C198.02 212.43 197.54 212.62 197.04 212.74C196.54 212.87 196.03 212.94 195.52 212.93C194.36 212.93 193.23 212.61 192.25 211.98C189.4 210.18 188.55 206.39 190.34 203.54C190.37 203.49 190.4 203.45 190.43 203.4C190.46 203.36 190.49 203.31 190.53 203.27C190.56 203.22 190.59 203.18 190.62 203.13C190.66 203.09 190.69 203.04 190.72 203C188.45 199.89 185.56 197.52 182.12 195.96C182.11 195.96 182.11 195.96 182.1 195.96C178.91 194.5 175.8 193.96 173.35 193.82L173.35 189.82C176.13 189.98 179.67 190.54 183.36 192.14C184.59 192.16 189.66 191.97 193.3 187.85C192.96 187.36 192.69 186.82 192.51 186.25C192.32 185.68 192.22 185.09 192.21 184.49C192.2 183.89 192.27 183.3 192.43 182.72C192.59 182.14 192.84 181.59 193.16 181.09C193.43 180.65 193.76 180.25 194.13 179.9C194.51 179.55 194.92 179.24 195.38 178.99C195.83 178.74 196.31 178.55 196.81 178.43C197.3 178.3 197.82 178.24 198.33 178.24C198.62 178.24 198.91 178.26 199.19 178.3C199.47 178.34 199.76 178.4 200.03 178.48C200.31 178.56 200.58 178.66 200.84 178.78C201.1 178.89 201.35 179.03 201.6 179.18C201.94 179.4 202.26 179.64 202.55 179.92C202.84 180.2 203.1 180.5 203.34 180.83C203.57 181.16 203.77 181.51 203.93 181.88C204.09 182.25 204.22 182.63 204.3 183.03ZM193.73 205.67C193.59 205.91 193.49 206.17 193.44 206.45C193.39 206.72 193.4 207 193.46 207.27C193.52 207.54 193.63 207.8 193.79 208.03C193.95 208.25 194.16 208.45 194.39 208.6C194.63 208.75 194.89 208.85 195.16 208.89C195.44 208.94 195.72 208.93 195.99 208.87C196.26 208.81 196.51 208.7 196.74 208.54C196.97 208.38 197.16 208.17 197.31 207.94C197.46 207.7 197.56 207.44 197.6 207.17C197.65 206.89 197.64 206.61 197.58 206.34C197.52 206.07 197.41 205.81 197.25 205.59C197.09 205.36 196.88 205.17 196.65 205.02C196.41 204.87 196.15 204.77 195.88 204.73C195.61 204.68 195.33 204.69 195.06 204.75C194.79 204.81 194.53 204.92 194.3 205.08C194.08 205.24 193.88 205.44 193.73 205.67ZM200.11 185.49C200.32 185.17 200.43 184.8 200.44 184.42C200.45 184.05 200.36 183.67 200.18 183.34C200 183.01 199.73 182.73 199.41 182.54C199.08 182.35 198.71 182.25 198.33 182.25C198.15 182.25 197.98 182.27 197.81 182.31C197.63 182.35 197.47 182.42 197.31 182.51C197.16 182.59 197.01 182.7 196.88 182.82C196.76 182.94 196.64 183.08 196.55 183.23C196.4 183.45 196.3 183.7 196.25 183.97C196.2 184.23 196.21 184.5 196.26 184.76C196.31 185.03 196.41 185.28 196.55 185.5C196.7 185.73 196.89 185.92 197.11 186.08L197.2 186.14C198.18 186.76 199.51 186.44 200.11 185.49Z" /> + <path id="Layer" fill-rule="evenodd" class="shp4" d="M213 224.01C210.76 224.39 209.57 225.15 208.14 226.08C206.98 226.83 205.74 227.64 203.93 228.27C208.47 231.6 214.56 237.87 217.12 242.06C217.52 241.95 217.94 241.89 218.35 241.87C218.77 241.85 219.19 241.87 219.6 241.93C220.01 242 220.42 242.11 220.81 242.25C221.2 242.4 221.58 242.58 221.93 242.81C222.27 243.02 222.59 243.27 222.88 243.55C223.17 243.82 223.44 244.13 223.67 244.46C223.9 244.79 224.1 245.14 224.26 245.51C224.43 245.88 224.55 246.26 224.64 246.65C224.73 247.04 224.78 247.44 224.79 247.84C224.8 248.24 224.77 248.64 224.7 249.03C224.63 249.43 224.52 249.81 224.38 250.19C224.23 250.56 224.05 250.92 223.83 251.25C223.56 251.69 223.23 252.09 222.86 252.44C222.48 252.8 222.07 253.1 221.62 253.35C221.16 253.6 220.68 253.79 220.19 253.92C219.69 254.04 219.17 254.11 218.66 254.1C217.5 254.1 216.37 253.78 215.39 253.16C215.26 253.07 215.18 253.02 215.11 252.97C212.46 251.1 211.75 247.47 213.49 244.71C213.59 244.56 213.69 244.42 213.8 244.28C211.13 239.63 201.64 230.6 199.03 229.96C195.63 229.11 193.67 229.18 191.18 229.27C189.73 229.32 188.08 229.39 185.97 229.27C182.89 229.11 181.2 227.19 179.71 225.49C178.15 223.71 176.65 222.12 173.36 221.82L173.36 217.82C178.5 218.2 180.92 220.79 182.72 222.85C184.08 224.39 184.85 225.2 186.18 225.27C188.12 225.37 189.6 225.32 191.03 225.27C192.77 225.2 194.36 225.15 196.32 225.38C202 225.29 203.92 224.04 205.96 222.72C207.58 221.66 209.41 220.48 212.78 219.99C212.82 219.85 212.87 219.71 212.92 219.58C212.97 219.44 213.02 219.31 213.08 219.17C213.14 219.04 213.2 218.91 213.27 218.78C213.34 218.66 213.41 218.53 213.49 218.41C213.54 218.34 213.59 218.27 213.64 218.19C213.69 218.12 213.74 218.05 213.79 217.98C213.84 217.92 213.9 217.85 213.95 217.78C214.01 217.71 214.07 217.65 214.12 217.58C214.26 217.44 214.41 217.32 214.55 217.19C213.44 213.09 213.24 207.29 214.59 202.77C214.56 202.75 214.54 202.73 214.51 202.71C213.99 202.23 213.56 201.66 213.23 201.03C212.9 200.4 212.69 199.72 212.59 199.01C212.5 198.31 212.53 197.59 212.68 196.9C212.84 196.21 213.11 195.55 213.49 194.95C213.76 194.51 214.09 194.11 214.46 193.76C214.84 193.4 215.26 193.1 215.71 192.85C216.16 192.6 216.64 192.41 217.14 192.28C217.64 192.16 218.15 192.09 218.66 192.1C218.95 192.1 219.24 192.12 219.52 192.16C219.81 192.2 220.09 192.26 220.36 192.34C220.64 192.42 220.91 192.52 221.17 192.63C221.43 192.75 221.69 192.89 221.93 193.04C222.27 193.25 222.59 193.5 222.88 193.78C223.17 194.06 223.44 194.36 223.67 194.69C223.9 195.02 224.1 195.37 224.26 195.74C224.42 196.11 224.55 196.49 224.64 196.88C224.73 197.27 224.78 197.67 224.79 198.07C224.8 198.47 224.77 198.87 224.7 199.27C224.63 199.66 224.52 200.05 224.38 200.42C224.23 200.79 224.05 201.15 223.83 201.49C223.56 201.92 223.23 202.32 222.86 202.68C222.48 203.03 222.07 203.33 221.62 203.58C221.16 203.83 220.68 204.02 220.19 204.15C219.69 204.27 219.17 204.34 218.66 204.34C218.55 204.34 218.44 204.32 218.32 204.31C217.37 207.81 217.58 212.42 218.29 215.58C218.41 215.57 218.54 215.56 218.66 215.56C218.95 215.56 219.24 215.58 219.52 215.62C219.81 215.66 220.09 215.72 220.36 215.8C220.64 215.88 220.91 215.98 221.17 216.1C221.43 216.21 221.69 216.35 221.93 216.5C222.27 216.72 222.59 216.96 222.88 217.24C223.17 217.52 223.44 217.82 223.67 218.15C223.9 218.48 224.1 218.83 224.26 219.2C224.42 219.57 224.55 219.95 224.64 220.35C224.73 220.74 224.78 221.14 224.79 221.54C224.8 221.94 224.77 222.34 224.7 222.73C224.63 223.12 224.52 223.51 224.38 223.88C224.23 224.26 224.05 224.61 223.83 224.95C223.56 225.39 223.23 225.79 222.86 226.14C222.48 226.49 222.07 226.8 221.62 227.05C221.16 227.29 220.68 227.48 220.19 227.61C219.69 227.74 219.17 227.8 218.66 227.8C218.34 227.8 218.03 227.78 217.72 227.73C217.4 227.68 217.1 227.6 216.8 227.51C216.49 227.41 216.2 227.29 215.92 227.15C215.64 227.01 215.37 226.84 215.11 226.66C214.88 226.5 214.65 226.31 214.45 226.12C214.24 225.92 214.04 225.71 213.87 225.49C213.69 225.26 213.52 225.03 213.38 224.78C213.24 224.53 213.11 224.28 213 224.01ZM216.88 197.09C216.74 197.31 216.64 197.56 216.59 197.83C216.54 198.09 216.54 198.36 216.59 198.62C216.64 198.88 216.74 199.14 216.89 199.36C217.03 199.59 217.22 199.78 217.44 199.94L217.53 200C218.51 200.62 219.85 200.3 220.44 199.34C220.52 199.23 220.58 199.11 220.63 198.98C220.68 198.85 220.72 198.72 220.74 198.58C220.77 198.44 220.78 198.31 220.78 198.17C220.77 198.03 220.76 197.89 220.73 197.76C220.6 197.2 220.27 196.73 219.79 196.43C219.45 196.22 219.06 196.1 218.66 196.1C218.49 196.1 218.31 196.13 218.14 196.17C217.97 196.21 217.8 196.28 217.64 196.36C217.49 196.45 217.35 196.55 217.22 196.68C217.09 196.8 216.97 196.93 216.88 197.09ZM216.88 220.55C216.74 220.77 216.64 221.03 216.59 221.29C216.54 221.55 216.54 221.82 216.59 222.09C216.64 222.35 216.74 222.6 216.89 222.82C217.03 223.05 217.22 223.24 217.44 223.4L217.53 223.46C218.51 224.08 219.85 223.76 220.44 222.81C220.52 222.69 220.58 222.57 220.63 222.44C220.68 222.31 220.72 222.18 220.74 222.04C220.77 221.91 220.78 221.77 220.78 221.63C220.77 221.49 220.76 221.36 220.72 221.22C220.6 220.67 220.27 220.2 219.79 219.89C219.45 219.68 219.06 219.57 218.66 219.57C218.49 219.57 218.31 219.59 218.14 219.63C217.97 219.68 217.8 219.74 217.64 219.83C217.49 219.91 217.35 220.02 217.22 220.14C217.09 220.26 216.97 220.4 216.88 220.55ZM216.88 246.85C216.74 247.08 216.64 247.33 216.59 247.59C216.54 247.86 216.54 248.13 216.59 248.39C216.64 248.65 216.74 248.9 216.89 249.13C217.03 249.35 217.22 249.55 217.44 249.7L217.53 249.76C218.51 250.38 219.85 250.06 220.44 249.11C220.52 249 220.58 248.87 220.63 248.74C220.68 248.62 220.72 248.48 220.74 248.35C220.77 248.21 220.78 248.07 220.78 247.94C220.77 247.8 220.76 247.66 220.72 247.53C220.6 246.97 220.27 246.5 219.79 246.2C219.45 245.98 219.06 245.87 218.66 245.87C218.49 245.87 218.31 245.89 218.14 245.94C217.97 245.98 217.8 246.05 217.64 246.13C217.49 246.22 217.35 246.32 217.22 246.44C217.09 246.56 216.97 246.7 216.88 246.85Z" /> + <g id="Layer" style="opacity: 0.251"> + <path id="Layer" class="shp2" d="M151.78 249.54C151.54 249.54 151.3 249.47 151.09 249.32C150.96 249.23 150.85 249.11 150.77 248.98C150.68 248.84 150.63 248.69 150.6 248.54C150.57 248.38 150.58 248.22 150.61 248.06C150.65 247.91 150.71 247.76 150.8 247.63C151.07 247.25 151.32 246.84 151.53 246.41C151.88 245.71 152.14 244.96 152.3 244.2C152.46 243.43 152.52 242.64 152.47 241.86C152.43 241.08 152.28 240.3 152.04 239.56C151.8 238.81 151.46 238.1 151.03 237.45C150.86 237.18 150.8 236.85 150.87 236.54C150.93 236.22 151.12 235.95 151.39 235.78C151.66 235.6 151.99 235.54 152.3 235.61C152.62 235.68 152.89 235.87 153.06 236.14C155.24 239.53 155.48 243.88 153.7 247.48C153.63 247.61 153.56 247.75 153.49 247.88C153.42 248.01 153.34 248.14 153.27 248.27C153.19 248.4 153.11 248.53 153.03 248.65C152.95 248.78 152.86 248.9 152.78 249.03C152.72 249.11 152.66 249.18 152.58 249.24C152.51 249.3 152.43 249.36 152.34 249.4C152.26 249.45 152.16 249.48 152.07 249.5C151.98 249.53 151.88 249.54 151.78 249.54M157.34 252.41C157.12 252.41 156.9 252.35 156.71 252.23C156.52 252.12 156.37 251.95 156.27 251.75C156.17 251.55 156.12 251.33 156.14 251.11C156.16 250.88 156.24 250.67 156.37 250.49C156.82 249.88 157.22 249.21 157.57 248.52C158.11 247.43 158.51 246.26 158.73 245.06C158.96 243.86 159.02 242.64 158.91 241.42C158.8 240.2 158.52 239.01 158.08 237.87C157.64 236.73 157.05 235.65 156.32 234.67C156.13 234.42 156.05 234.09 156.09 233.78C156.14 233.46 156.31 233.18 156.57 232.98C156.82 232.79 157.15 232.71 157.46 232.76C157.78 232.8 158.06 232.98 158.25 233.23C159.12 234.39 159.82 235.66 160.34 237C160.86 238.35 161.18 239.76 161.31 241.2C161.44 242.64 161.37 244.09 161.11 245.5C160.84 246.92 160.38 248.3 159.73 249.59C159.33 250.41 158.85 251.19 158.32 251.92C158.26 252 158.2 252.07 158.13 252.13C158.05 252.19 157.97 252.24 157.89 252.28C157.8 252.33 157.71 252.36 157.62 252.38C157.53 252.4 157.43 252.41 157.34 252.41ZM146.34 246.49C146.13 246.48 145.93 246.43 145.75 246.32C145.57 246.22 145.41 246.06 145.31 245.88C145.2 245.7 145.15 245.49 145.15 245.28C145.14 245.07 145.2 244.87 145.3 244.68C145.49 244.35 145.63 244 145.74 243.64C145.84 243.27 145.9 242.9 145.92 242.52C145.93 242.14 145.9 241.76 145.83 241.39C145.76 241.02 145.64 240.66 145.48 240.31C145.36 240.03 145.36 239.7 145.47 239.41C145.58 239.12 145.81 238.88 146.09 238.75C146.37 238.62 146.7 238.61 146.99 238.71C147.29 238.81 147.53 239.03 147.67 239.31C147.91 239.82 148.09 240.37 148.2 240.92C148.31 241.48 148.35 242.05 148.33 242.62C148.31 243.19 148.21 243.76 148.06 244.3C147.9 244.85 147.68 245.38 147.4 245.88C147.35 245.97 147.28 246.05 147.21 246.13C147.13 246.2 147.05 246.27 146.96 246.32C146.86 246.38 146.76 246.42 146.66 246.45C146.56 246.47 146.45 246.49 146.34 246.49" /> + </g> + <g id="Layer"> + <path id="Layer" class="shp0" d="M151.78 246.54C151.54 246.54 151.3 246.47 151.09 246.32C150.96 246.23 150.85 246.11 150.77 245.98C150.68 245.84 150.63 245.69 150.6 245.54C150.57 245.38 150.58 245.22 150.61 245.06C150.65 244.91 150.71 244.76 150.8 244.63C151.07 244.25 151.32 243.84 151.53 243.41C151.88 242.71 152.14 241.96 152.3 241.2C152.46 240.43 152.52 239.64 152.47 238.86C152.43 238.08 152.28 237.3 152.04 236.56C151.8 235.81 151.46 235.1 151.03 234.45C150.86 234.18 150.8 233.85 150.87 233.54C150.93 233.22 151.12 232.95 151.39 232.78C151.66 232.6 151.99 232.54 152.3 232.61C152.62 232.68 152.89 232.87 153.06 233.14C155.24 236.53 155.48 240.88 153.7 244.48C153.63 244.61 153.56 244.75 153.49 244.88C153.42 245.01 153.34 245.14 153.27 245.27C153.19 245.4 153.11 245.53 153.03 245.65C152.95 245.78 152.86 245.9 152.78 246.03C152.72 246.11 152.66 246.18 152.58 246.24C152.51 246.3 152.43 246.36 152.34 246.4C152.26 246.45 152.16 246.48 152.07 246.5C151.98 246.53 151.88 246.54 151.78 246.54M157.34 249.41C157.12 249.41 156.9 249.35 156.71 249.23C156.52 249.12 156.37 248.95 156.27 248.75C156.17 248.55 156.12 248.33 156.14 248.11C156.16 247.88 156.24 247.67 156.37 247.49C156.82 246.88 157.22 246.21 157.57 245.52C158.11 244.43 158.51 243.26 158.73 242.06C158.96 240.86 159.02 239.64 158.91 238.42C158.8 237.2 158.52 236.01 158.08 234.87C157.64 233.73 157.05 232.65 156.32 231.67C156.13 231.42 156.05 231.09 156.09 230.78C156.14 230.46 156.31 230.18 156.57 229.98C156.82 229.79 157.15 229.71 157.46 229.76C157.78 229.8 158.06 229.98 158.25 230.23C159.12 231.39 159.82 232.66 160.34 234C160.86 235.35 161.18 236.76 161.31 238.2C161.44 239.64 161.37 241.09 161.11 242.5C160.84 243.92 160.38 245.3 159.73 246.59C159.33 247.41 158.85 248.19 158.32 248.92C158.26 249 158.2 249.07 158.13 249.13C158.05 249.19 157.97 249.24 157.89 249.28C157.8 249.33 157.71 249.36 157.62 249.38C157.53 249.4 157.43 249.41 157.34 249.41ZM146.34 243.49C146.13 243.48 145.93 243.43 145.75 243.32C145.57 243.22 145.41 243.06 145.31 242.88C145.2 242.7 145.15 242.49 145.15 242.28C145.14 242.07 145.2 241.87 145.3 241.68C145.49 241.35 145.63 241 145.74 240.64C145.84 240.27 145.9 239.9 145.92 239.52C145.93 239.14 145.9 238.76 145.83 238.39C145.76 238.02 145.64 237.66 145.48 237.31C145.36 237.03 145.36 236.7 145.47 236.41C145.58 236.12 145.81 235.88 146.09 235.75C146.37 235.62 146.7 235.61 146.99 235.71C147.29 235.81 147.53 236.03 147.67 236.31C147.91 236.82 148.09 237.37 148.2 237.92C148.31 238.48 148.35 239.05 148.33 239.62C148.31 240.19 148.21 240.76 148.06 241.3C147.9 241.85 147.68 242.38 147.4 242.88C147.35 242.97 147.28 243.05 147.21 243.13C147.13 243.2 147.05 243.27 146.96 243.32C146.86 243.38 146.76 243.42 146.66 243.45C146.56 243.47 146.45 243.49 146.34 243.49" /> + </g> + <g id="Layer"> + <path id="Layer" fill-rule="evenodd" class="shp5" d="M164.08 192.24C165.2 192.24 167.16 192.28 168.36 192.39L168.36 195.88C167.32 195.78 165.12 195.74 164.08 195.74C154.95 195.74 145.38 199.12 139.72 204.35C135.15 208.57 132.19 214.61 131.03 221.84C130.8 223.29 130.66 225.08 130.6 226.94C130.89 225.07 132.31 223.65 134.03 223.65L135.93 223.65C137.86 223.65 139.42 225.44 139.42 227.65L139.42 257.32C139.42 259.53 137.86 261.32 135.93 261.32L134.03 261.32C132.1 261.32 130.53 259.53 130.53 257.32L130.53 255.72C130.24 255.79 129.94 255.83 129.62 255.83L127.59 255.83C127.06 255.83 126.54 255.73 126.06 255.53C125.57 255.33 125.13 255.03 124.76 254.66C124.39 254.29 124.09 253.85 123.89 253.36C123.69 252.88 123.59 252.36 123.59 251.83L123.59 233.71C123.59 233.24 123.67 232.77 123.84 232.32C124.01 231.88 124.25 231.47 124.56 231.11C124.87 230.75 125.24 230.45 125.66 230.22C126.08 229.99 126.53 229.84 127 229.77C126.82 218.03 130.46 208.14 137.35 201.78C143.72 195.9 153.96 192.24 164.08 192.24ZM129.62 251.43L130.54 251.43L130.54 234.12L129.62 234.12C129.27 234.12 128.93 234.18 128.61 234.32C128.29 234.45 127.99 234.65 127.75 234.89C127.5 235.14 127.31 235.43 127.17 235.75C127.04 236.07 126.97 236.42 126.97 236.77L126.97 248.78C126.97 249.13 127.04 249.47 127.17 249.79C127.31 250.11 127.5 250.41 127.75 250.65C128 250.9 128.29 251.09 128.61 251.23C128.93 251.36 129.27 251.43 129.62 251.43Z" /> + <path id="Layer" fill-rule="evenodd" class="shp4" d="M164.08 189.24C165.2 189.24 167.16 189.28 168.36 189.39L168.36 192.88C167.32 192.78 165.12 192.74 164.08 192.74C154.95 192.74 145.38 196.12 139.72 201.35C135.15 205.57 132.19 211.61 131.03 218.84C130.8 220.29 130.66 222.08 130.6 223.94C130.89 222.07 132.31 220.65 134.03 220.65L135.93 220.65C137.86 220.65 139.42 222.44 139.42 224.65L139.42 254.32C139.42 256.53 137.86 258.32 135.93 258.32L134.03 258.32C132.1 258.32 130.53 256.53 130.53 254.32L130.53 252.72C130.24 252.79 129.94 252.83 129.62 252.83L127.59 252.83C127.06 252.83 126.54 252.73 126.06 252.53C125.57 252.33 125.13 252.03 124.76 251.66C124.39 251.29 124.09 250.85 123.89 250.36C123.69 249.88 123.59 249.36 123.59 248.83L123.59 230.71C123.59 230.24 123.67 229.77 123.84 229.32C124.01 228.88 124.25 228.47 124.56 228.11C124.87 227.75 125.24 227.45 125.66 227.22C126.08 226.99 126.53 226.84 127 226.77C126.82 215.03 130.46 205.14 137.35 198.78C143.72 192.9 153.96 189.24 164.08 189.24ZM129.62 248.43L130.54 248.43L130.54 231.12L129.62 231.12C129.27 231.12 128.93 231.18 128.61 231.32C128.29 231.45 127.99 231.65 127.75 231.89C127.5 232.14 127.31 232.43 127.17 232.75C127.04 233.07 126.97 233.42 126.97 233.77L126.97 245.78C126.97 246.13 127.04 246.47 127.17 246.79C127.31 247.11 127.5 247.41 127.75 247.65C128 247.9 128.29 248.09 128.61 248.23C128.93 248.36 129.27 248.43 129.62 248.43Z" /> + </g> + <g id="Layer"> + <path id="Layer" fill-rule="evenodd" class="shp1" d="M295.75 242.67L295.75 252.82L259.58 252.82L259.58 198.08L272.78 198.08L272.78 242.67L295.75 242.67ZM300.63 201.65C300.63 199.85 301.29 198.37 302.59 197.22C303.89 196.06 305.59 195.49 307.67 195.49C309.75 195.49 311.44 196.06 312.74 197.22C314.04 198.37 314.7 199.85 314.7 201.65C314.7 203.46 314.05 204.94 312.74 206.09C311.44 207.24 309.75 207.82 307.67 207.82C305.59 207.82 303.89 207.24 302.59 206.09C301.29 204.94 300.63 203.46 300.63 201.65ZM314.1 252.82L301.39 252.82L301.39 212.14L314.1 212.14L314.1 252.82ZM341.5 238.8C340.38 238.15 338.23 237.47 335.07 236.77C331.92 236.07 329.31 235.15 327.25 234.01C325.2 232.87 323.63 231.48 322.55 229.85C321.48 228.22 320.94 226.36 320.94 224.25C320.94 220.51 322.48 217.44 325.56 215.02C328.64 212.6 332.68 211.39 337.67 211.39C343.03 211.39 347.34 212.61 350.6 215.04C353.86 217.47 355.49 220.66 355.49 224.63L342.78 224.63C342.78 221.37 341.06 219.74 337.63 219.74C336.3 219.74 335.19 220.11 334.28 220.85C333.38 221.58 332.93 222.51 332.93 223.61C332.93 224.74 333.48 225.65 334.58 226.35C335.69 227.06 337.45 227.63 339.87 228.08C342.29 228.53 344.41 229.07 346.24 229.7C352.36 231.8 355.41 235.58 355.41 241.02C355.41 244.73 353.77 247.75 350.47 250.08C347.17 252.41 342.91 253.57 337.67 253.57C334.18 253.57 331.08 252.95 328.34 251.69C325.61 250.44 323.48 248.74 321.95 246.58C320.42 244.43 319.66 242.16 319.66 239.77L331.5 239.77C331.55 241.66 332.18 243.03 333.38 243.89C334.58 244.76 336.13 245.19 338.01 245.19C339.74 245.19 341.03 244.84 341.9 244.14C342.76 243.44 343.19 242.52 343.19 241.39C343.19 240.32 342.63 239.45 341.5 238.8ZM376.09 212.14L382.79 212.14L382.79 220.94L376.09 220.94L376.09 239.55C376.09 241.08 376.37 242.15 376.92 242.75C377.47 243.35 378.56 243.65 380.19 243.65C381.44 243.65 382.5 243.57 383.35 243.42L383.35 252.49C382.77 252.67 382.19 252.83 381.6 252.96C381.01 253.1 380.42 253.22 379.82 253.31C379.22 253.4 378.62 253.47 378.02 253.51C377.41 253.56 376.81 253.58 376.21 253.57C371.84 253.57 368.62 252.55 366.54 250.49C364.46 248.44 363.42 245.32 363.42 241.13L363.42 220.94L358.23 220.94L358.23 212.14L363.42 212.14L363.42 202.03L376.09 202.03L376.09 212.14ZM392.3 247.99C388.44 244.27 386.51 239.43 386.51 233.46L386.51 232.41C386.51 228.25 387.28 224.58 388.82 221.39C390.36 218.21 392.61 215.75 395.57 214C398.53 212.26 402.04 211.39 406.1 211.39C411.81 211.39 416.32 213.16 419.63 216.71C422.94 220.26 424.59 225.2 424.59 231.54L424.59 236.47L399.41 236.47C399.86 238.75 400.85 240.54 402.38 241.84C403.9 243.15 405.88 243.8 408.32 243.8C412.33 243.8 415.46 242.4 417.72 239.59L423.5 246.43C421.93 248.61 419.69 250.35 416.79 251.64C413.9 252.93 410.78 253.57 407.45 253.57C401.21 253.57 396.16 251.71 392.3 247.99ZM399.4 228.53L412.19 228.53L412.19 227.56C412.24 225.53 411.72 223.95 410.65 222.84C409.57 221.72 408.03 221.17 406.02 221.17C402.31 221.17 400.11 223.62 399.4 228.53ZM441.74 216.92C444.55 213.23 448.42 211.39 453.36 211.39C457.59 211.39 460.76 212.66 462.85 215.19C464.94 217.72 466.03 221.53 466.1 226.62L466.1 252.82L453.39 252.82L453.39 227.14C453.39 225.09 452.98 223.58 452.15 222.61C451.33 221.65 449.82 221.17 447.64 221.17C445.16 221.17 443.32 222.14 442.11 224.1L442.11 252.82L429.44 252.82L429.44 212.14L441.32 212.14L441.74 216.92Z" /> + </g> + <g id="Layer"> + <path id="Layer" fill-rule="evenodd" class="shp0" d="M473.64 198.08L493.3 198.08C500.34 198.08 505.7 199.37 509.37 201.95C513.05 204.54 514.88 208.28 514.88 213.2C514.88 216.03 514.23 218.45 512.93 220.45C511.62 222.46 509.71 223.94 507.18 224.89C510.03 225.64 512.23 227.05 513.75 229.1C515.28 231.15 516.05 233.66 516.05 236.62C516.05 241.98 514.35 246.01 510.95 248.71C507.56 251.4 502.52 252.77 495.86 252.82L473.64 252.82L473.64 198.08ZM486.83 220.9L493.72 220.9C496.6 220.88 498.65 220.35 499.88 219.32C501.11 218.29 501.72 216.77 501.72 214.76C501.72 212.44 501.06 210.78 499.73 209.76C498.4 208.74 496.26 208.23 493.3 208.23L486.83 208.23L486.83 220.9ZM486.83 229.55L486.83 242.67L495.48 242.67C497.86 242.67 499.69 242.13 500.97 241.04C502.25 239.95 502.89 238.41 502.89 236.43C502.89 231.87 500.62 229.58 496.08 229.55L486.83 229.55ZM547.33 223.61L543.15 223.31C539.17 223.31 536.61 224.56 535.48 227.07L535.48 252.82L522.81 252.82L522.81 212.14L534.69 212.14L535.11 217.37C537.24 213.38 540.21 211.39 544.02 211.39C545.37 211.39 546.55 211.54 547.55 211.84L547.33 223.61ZM573.73 252.82C573.28 252 572.88 250.78 572.53 249.18C570.2 252.11 566.94 253.57 562.76 253.57C558.92 253.57 555.66 252.42 552.98 250.1C550.3 247.78 548.96 244.87 548.96 241.36C548.96 236.94 550.59 233.61 553.85 231.35C557.11 229.1 561.84 227.97 568.06 227.97L571.97 227.97L571.97 225.82C571.97 222.06 570.35 220.19 567.12 220.19C564.11 220.19 562.61 221.67 562.61 224.65L549.94 224.65C549.94 220.72 551.61 217.52 554.96 215.07C558.3 212.62 562.57 211.39 567.76 211.39C572.95 211.39 577.04 212.66 580.05 215.19C583.06 217.72 584.6 221.19 584.68 225.6L584.68 243.61C584.73 247.34 585.3 250.2 586.41 252.18L586.41 252.82L573.73 252.82L573.73 252.82ZM569.73 243.54C570.77 242.86 571.52 242.1 571.97 241.24L571.97 234.74L568.28 234.74C563.87 234.74 561.67 236.72 561.67 240.68C561.67 241.83 562.05 242.76 562.83 243.48C563.61 244.19 564.6 244.55 565.8 244.55C567.38 244.55 568.69 244.21 569.73 243.54ZM591.59 201.65C591.59 199.85 592.25 198.37 593.55 197.22C594.85 196.06 596.54 195.49 598.63 195.49C600.7 195.49 602.4 196.06 603.7 197.22C605 198.37 605.65 199.85 605.65 201.65C605.65 203.46 605 204.94 603.7 206.09C602.4 207.24 600.7 207.82 598.63 207.82C596.54 207.82 594.85 207.24 593.55 206.09C592.25 204.94 591.59 203.46 591.59 201.65ZM605.05 252.82L592.35 252.82L592.35 212.14L605.05 212.14L605.05 252.82ZM624.49 212.14L624.91 216.92C627.71 213.23 631.58 211.39 636.52 211.39C640.76 211.39 643.92 212.66 646.02 215.19C648.11 217.72 649.19 221.53 649.27 226.62L649.27 252.82L636.56 252.82L636.56 227.14C636.56 225.09 636.15 223.58 635.32 222.61C634.49 221.65 632.99 221.17 630.81 221.17C628.33 221.17 626.48 222.14 625.28 224.1L625.28 252.82L612.61 252.82L612.61 212.14L624.49 212.14ZM689.76 243.05L689.76 252.82L655.21 252.82L655.21 245.75L673.26 221.92L656 221.92L656 212.14L689.35 212.14L689.35 218.99L671.23 243.05L689.76 243.05Z" /> + </g> +</svg>
\ No newline at end of file diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/NOTICE.md b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/NOTICE.md new file mode 100644 index 0000000000..774e383f8b --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/NOTICE.md @@ -0,0 +1,23 @@ +# ListenBrainz logo attribution + +The file `ListenBrainz_logo.svg` shipped alongside this plugin is a derivative +work used here under the terms of the Creative Commons Attribution-ShareAlike +4.0 International license (CC BY-SA 4.0). + +## Attribution chain + +1. Original work: [ListenBrainz logo](https://github.com/metabrainz/metabrainz-logos/commit/10127d3e84e5bb7e1c8509f1da12223d19581e18) + by [MonkeyDo](https://github.com/metabrainz/metabrainz-logos/commits?author=MonkeyDo) + at the [MetaBrainz Foundation](https://github.com/metabrainz), licensed under + CC BY-SA 4.0. +2. "ListenBrainz logo for Jellyfin plugin" — derivative by + [lyarenei](https://github.com/lyarenei), distributed in + [jellyfin-plugin-listenbrainz](https://github.com/lyarenei/jellyfin-plugin-listenbrainz/tree/main/res/listenbrainz) + under CC BY-SA 4.0. +3. This redistribution within Jellyfin retains the work unmodified and remains + licensed under CC BY-SA 4.0 per the license's ShareAlike requirement. + +## License + +A full copy of the CC BY-SA 4.0 license is available at +<https://creativecommons.org/licenses/by-sa/4.0/legalcode>. diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000000..6f60d18c33 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs @@ -0,0 +1,65 @@ +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; + +/// <summary> +/// ListenBrainz plugin configuration. +/// </summary> +public class PluginConfiguration : BasePluginConfiguration +{ + /// <summary> + /// The default Labs API server URL. + /// </summary> + public const string DefaultLabsServer = "https://labs.api.listenbrainz.org"; + + /// <summary> + /// The default rate limit in seconds. + /// </summary> + public const double DefaultRateLimit = 1.0; + + private string _labsServer = DefaultLabsServer; + private double _rateLimit = DefaultRateLimit; + + /// <summary> + /// Gets or sets the Labs API server URL. + /// </summary> + public string LabsServer + { + get => _labsServer; + set => _labsServer = string.IsNullOrWhiteSpace(value) ? DefaultLabsServer : value.TrimEnd('/'); + } + + /// <summary> + /// Gets or sets the similarity algorithm. + /// </summary> + public SimilarityAlgorithm Algorithm { get; set; } = SimilarityAlgorithm.SessionBased1825Days; + + /// <summary> + /// Gets or sets the rate limit in seconds. + /// </summary> + public double RateLimit + { + get => _rateLimit; + set + { + if (value < DefaultRateLimit && _labsServer == DefaultLabsServer) + { + _rateLimit = DefaultRateLimit; + } + else + { + _rateLimit = value; + } + } + } + + /// <summary> + /// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching. + /// </summary> + public int SimilarItemsCacheDays { get; set; } = 14; + + /// <summary> + /// Gets the algorithm string for the API call. + /// </summary> + public string AlgorithmString => Algorithm.ToApiString(); +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs new file mode 100644 index 0000000000..f297d99f6d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs @@ -0,0 +1,37 @@ +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; + +/// <summary> +/// Available similarity algorithms for ListenBrainz Labs API. +/// </summary> +public enum SimilarityAlgorithm +{ + /// <summary> + /// Session-based algorithm analyzing ~5 years of listening data. + /// </summary> + SessionBased1825Days = 0, + + /// <summary> + /// Session-based algorithm analyzing ~5 years of listening data (alternate). + /// </summary> + SessionBased1800Days = 1, + + /// <summary> + /// Session-based algorithm analyzing ~20 years of listening data. + /// </summary> + SessionBased7500Days = 2, + + /// <summary> + /// Session-based algorithm analyzing ~20 years with higher contribution threshold. + /// </summary> + SessionBased7500DaysHighContribution = 3, + + /// <summary> + /// Session-based algorithm analyzing ~25 years of listening data. + /// </summary> + SessionBased9000Days = 4, + + /// <summary> + /// Session-based algorithm analyzing ~75 days of recent listening data. + /// </summary> + SessionBased75Days = 5 +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs new file mode 100644 index 0000000000..f7874dbae8 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; + +/// <summary> +/// Extension methods for <see cref="SimilarityAlgorithm"/>. +/// </summary> +public static class SimilarityAlgorithmExtensions +{ + /// <summary> + /// Gets the API string value for the algorithm. + /// </summary> + /// <param name="algorithm">The algorithm.</param> + /// <returns>The API string value.</returns> + public static string ToApiString(this SimilarityAlgorithm algorithm) => algorithm switch + { + SimilarityAlgorithm.SessionBased1825Days => "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased1800Days => "session_based_days_1800_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased7500Days => "session_based_days_7500_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased7500DaysHighContribution => "session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased9000Days => "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30", + SimilarityAlgorithm.SessionBased75Days => "session_based_days_75_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30", + _ => "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30" + }; +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html new file mode 100644 index 0000000000..dec21d1b42 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html @@ -0,0 +1,109 @@ +<!DOCTYPE html> +<html> +<head> + <title>ListenBrainz</title> +</head> +<body> + <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select"> + <div data-role="content"> + <div class="content-primary"> + <img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" /> + <h1>ListenBrainz</h1> + <p>Get similar artist recommendations from ListenBrainz Labs.</p> + <form class="configForm"> + <div class="inputContainer"> + <input is="emby-input" type="text" id="labsServer" required label="Labs API Server" /> + <div class="fieldDescription">The ListenBrainz Labs API server URL. Default: https://labs.api.listenbrainz.org</div> + </div> + <div class="selectContainer"> + <label class="selectLabel" for="algorithm">Similarity Algorithm</label> + <select is="emby-select" id="algorithm" class="emby-select-withcolor"> + <option value="0" selected>~5 years / 1825 days (Recommended)</option> + <option value="1">~5 years / 1800 days</option> + <option value="2">~20 years / 7500 days</option> + <option value="3">~20 years / 7500 days (high contribution)</option> + <option value="4">~25 years / 9000 days</option> + <option value="5">~75 days (recent)</option> + </select> + <div class="fieldDescription">The algorithm used for artist similarity calculation.</div> + </div> + <div class="inputContainer"> + <input is="emby-input" type="number" id="rateLimit" required pattern="[0-9]*" min="0" max="10" step=".01" label="Rate Limit (seconds)" /> + <div class="fieldDescription">Span of time between requests in seconds. The official server is rate limited to one request per second.</div> + </div> + <div class="inputContainer"> + <input is="emby-input" type="number" id="similarItemsCacheDays" required pattern="[0-9]*" min="0" max="365" label="Cache duration (days)" /> + <div class="fieldDescription">Number of days to cache similar artist results from ListenBrainz. Set to 0 to disable caching.</div> + </div> + <br /> + <div> + <button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button> + </div> + </form> + <div class="verticalSection" style="margin-top:2em;font-size:0.85em;opacity:0.8;"> + <p>The ListenBrainz logo is © the MetaBrainz Foundation (by MonkeyDo), + adapted for Jellyfin plugin use by + <a href="https://github.com/lyarenei" target="_blank" rel="noopener">lyarenei</a>, + and redistributed here under + <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>. + Full attribution notice is shipped alongside the plugin in <code>NOTICE.md</code>.</p> + </div> + </div> + </div> + <script type="text/javascript"> + var ListenBrainzPluginConfig = { + uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e" + }; + + document.querySelector('.configPage') + .addEventListener('pageshow', function () { + Dashboard.showLoadingMsg(); + document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' }); + ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { + var labsServer = document.querySelector('#labsServer'); + labsServer.value = config.LabsServer; + labsServer.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + + document.querySelector('#algorithm').value = config.Algorithm; + + var rateLimit = document.querySelector('#rateLimit'); + rateLimit.value = config.RateLimit; + rateLimit.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + + var similarItemsCacheDays = document.querySelector('#similarItemsCacheDays'); + similarItemsCacheDays.value = config.SimilarItemsCacheDays; + similarItemsCacheDays.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + + Dashboard.hideLoadingMsg(); + }); + }); + + document.querySelector('.configForm') + .addEventListener('submit', function (e) { + Dashboard.showLoadingMsg(); + + ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { + config.LabsServer = document.querySelector('#labsServer').value; + config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10); + config.RateLimit = document.querySelector('#rateLimit').value; + config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10); + + ApiClient.updatePluginConfiguration(ListenBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); + }); + + e.preventDefault(); + return false; + }); + </script> + </div> +</body> +</html> diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs new file mode 100644 index 0000000000..1681f0334d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs @@ -0,0 +1,63 @@ +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; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz; + +/// <summary> +/// ListenBrainz plugin instance. +/// </summary> +public class ListenBrainzPlugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage +{ + /// <summary> + /// Initializes a new instance of the <see cref="ListenBrainzPlugin"/> class. + /// </summary> + /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> + public ListenBrainzPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// <summary> + /// Gets the current plugin instance. + /// </summary> + public static ListenBrainzPlugin? Instance { get; private set; } + + /// <inheritdoc /> + public override Guid Id => new("a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e"); + + /// <inheritdoc /> + public override string Name => "ListenBrainz Similarity Provider"; + + /// <inheritdoc /> + public override string Description => "Get similar artist recommendations from ListenBrainz Labs."; + + /// <inheritdoc /> + 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."; + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = resourcePrefix + "config.html" + }; + yield return new PluginPageInfo + { + Name = Name + "Notice", + EmbeddedResourcePath = resourcePrefix + "NOTICE.md" + }; + } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs new file mode 100644 index 0000000000..3dca748d06 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz; + +/// <summary> +/// ListenBrainz-based similar items provider for music artists. +/// </summary> +public class ListenBrainzSimilarArtistProvider : IRemoteSimilarItemsProvider<MusicArtist> +{ + private readonly ListenBrainzLabsClient _labsClient; + private readonly ILogger<ListenBrainzSimilarArtistProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ListenBrainzSimilarArtistProvider"/> class. + /// </summary> + /// <param name="labsClient">The ListenBrainz Labs API client.</param> + /// <param name="logger">The logger.</param> + public ListenBrainzSimilarArtistProvider( + ListenBrainzLabsClient labsClient, + ILogger<ListenBrainzSimilarArtistProvider> logger) + { + _labsClient = labsClient; + _logger = logger; + } + + /// <inheritdoc/> + public string Name => "ListenBrainz"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// <inheritdoc/> + public TimeSpan? CacheDuration + { + get + { + var days = ListenBrainzPlugin.Instance?.Configuration.SimilarItemsCacheDays ?? 0; + return days > 0 ? TimeSpan.FromDays(days) : null; + } + } + + /// <inheritdoc/> + public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + MusicArtist item, + SimilarItemsQuery query, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(query); + + if (!item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var mbidStr) || !Guid.TryParse(mbidStr, out var mbid)) + { + _logger.LogDebug("No MusicBrainz Artist ID found for {ArtistName}", item.Name); + yield break; + } + + IReadOnlyList<Guid> similarMbids; + try + { + similarMbids = await _labsClient.GetSimilarArtistsAsync(mbid, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to fetch similar artists from ListenBrainz for {ArtistMbid}", mbid); + yield break; + } + + var providerName = MetadataProvider.MusicBrainzArtist.ToString(); + + foreach (var similarMbid in similarMbids) + { + yield return new SimilarItemReference + { + ProviderName = providerName, + ProviderId = similarMbid.ToString() + }; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 88c8e4f7c9..715bdd9da4 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -9,81 +9,41 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Providers; using MediaBrowser.Providers.Music; -using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; using MetaBrainz.MusicBrainz; using MetaBrainz.MusicBrainz.Interfaces.Entities; using MetaBrainz.MusicBrainz.Interfaces.Searches; -using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// <summary> /// Music album metadata provider for MusicBrainz. /// </summary> -public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable +public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder { - private readonly ILogger<MusicBrainzAlbumProvider> _logger; - private Query _musicBrainzQuery; - - /// <summary> - /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class. - /// </summary> - /// <param name="logger">The logger.</param> - public MusicBrainzAlbumProvider(ILogger<MusicBrainzAlbumProvider> logger) - { - _logger = logger; - _musicBrainzQuery = new Query(); - ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration); - MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig; - } - /// <inheritdoc /> public string Name => "MusicBrainz"; /// <inheritdoc /> public int Order => 0; - private void ReloadConfig(object? sender, BasePluginConfiguration e) - { - var configuration = (PluginConfiguration)e; - if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server)) - { - Query.DefaultServer = server.DnsSafeHost; - Query.DefaultPort = server.Port; - Query.DefaultUrlScheme = server.Scheme; - } - else - { - // Fallback to official server - _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server"); - var defaultServer = new Uri(PluginConfiguration.DefaultServer); - Query.DefaultServer = defaultServer.Host; - Query.DefaultPort = defaultServer.Port; - Query.DefaultUrlScheme = defaultServer.Scheme; - } - - Query.DelayBetweenRequests = configuration.RateLimit; - _musicBrainzQuery = new Query(); - } - /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) { + var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; var releaseId = searchInfo.GetReleaseId(); var releaseGroupId = searchInfo.GetReleaseGroupId(); if (!string.IsNullOrEmpty(releaseId)) { - var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); + var releaseResult = await query.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); return GetReleaseResult(releaseResult).SingleItemAsEnumerable(); } if (!string.IsNullOrEmpty(releaseGroupId)) { - var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false); + var releaseGroupResult = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false); // No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken); @@ -93,7 +53,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (!string.IsNullOrWhiteSpace(artistMusicBrainzId)) { - var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) .ConfigureAwait(false); if (releaseSearchResults.Results.Count > 0) @@ -106,7 +66,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu // I'm sure there is a better way but for now it resolves search for 12" Mixes var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal); - var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken) .ConfigureAwait(false); if (releaseSearchResults.Results.Count > 0) @@ -138,10 +98,11 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu yield break; } + var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; foreach (var result in releaseSearchResults) { // Fetch full release info, otherwise artists are missing - var fullResult = await _musicBrainzQuery.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); + var fullResult = await query.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); yield return GetReleaseResult(fullResult); } } @@ -195,6 +156,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) { // TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it. + var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; var releaseId = info.GetReleaseId(); var releaseGroupId = info.GetReleaseGroupId(); @@ -207,7 +169,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) { // TODO: Actually try to match the release. Simply taking the first result is stupid. - var releaseGroup = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); + var releaseGroup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null; if (release is not null) { @@ -224,13 +186,13 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (!string.IsNullOrEmpty(artistMusicBrainzId)) { - var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) .ConfigureAwait(false); releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; } else if (!string.IsNullOrEmpty(info.GetAlbumArtist())) { - var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken) + var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken) .ConfigureAwait(false); releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; } @@ -253,7 +215,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu // If we have a release ID but not a release group ID, lookup the release group if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) { - var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); + var release = await query.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); releaseGroupId = release.ReleaseGroup?.Id.ToString(); result.HasMetadata = true; } @@ -285,23 +247,4 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu { throw new NotImplementedException(); } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Dispose all resources. - /// </summary> - /// <param name="disposing">Whether to dispose.</param> - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _musicBrainzQuery.Dispose(); - } - } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 9df21596c5..0fe4e6bb16 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -8,37 +8,19 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Providers; using MediaBrowser.Providers.Music; -using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; using MetaBrainz.MusicBrainz; using MetaBrainz.MusicBrainz.Interfaces.Entities; using MetaBrainz.MusicBrainz.Interfaces.Searches; -using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// <summary> /// MusicBrainz artist provider. /// </summary> -public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable, IHasOrder +public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IHasOrder { - private readonly ILogger<MusicBrainzArtistProvider> _logger; - private Query _musicBrainzQuery; - - /// <summary> - /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class. - /// </summary> - /// <param name="logger">The logger.</param> - public MusicBrainzArtistProvider(ILogger<MusicBrainzArtistProvider> logger) - { - _logger = logger; - _musicBrainzQuery = new Query(); - ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration); - MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig; - } - /// <inheritdoc /> public string Name => "MusicBrainz"; @@ -46,41 +28,19 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar /// Runs first to populate the MusicBrainz artist ID used by downstream providers. public int Order => 0; - private void ReloadConfig(object? sender, BasePluginConfiguration e) - { - var configuration = (PluginConfiguration)e; - if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server)) - { - Query.DefaultServer = server.DnsSafeHost; - Query.DefaultPort = server.Port; - Query.DefaultUrlScheme = server.Scheme; - } - else - { - // Fallback to official server - _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server"); - var defaultServer = new Uri(PluginConfiguration.DefaultServer); - Query.DefaultServer = defaultServer.Host; - Query.DefaultPort = defaultServer.Port; - Query.DefaultUrlScheme = defaultServer.Scheme; - } - - Query.DelayBetweenRequests = configuration.RateLimit; - _musicBrainzQuery = new Query(); - } - /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) { + var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; var artistId = searchInfo.GetMusicBrainzArtistId(); if (!string.IsNullOrWhiteSpace(artistId)) { - var artistResult = await _musicBrainzQuery.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false); + var artistResult = await query.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false); return GetResultFromResponse(artistResult).SingleItemAsEnumerable(); } - var artistSearchResults = await _musicBrainzQuery.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) + var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) .ConfigureAwait(false); if (artistSearchResults.Results.Count > 0) { @@ -90,7 +50,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar if (searchInfo.Name.HasDiacritics()) { // Try again using the search with an accented characters query - var artistAccentsSearchResults = await _musicBrainzQuery.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken) + var artistAccentsSearchResults = await query.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken) .ConfigureAwait(false); if (artistAccentsSearchResults.Results.Count > 0) { @@ -168,23 +128,4 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar { throw new NotImplementedException(); } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Dispose all resources. - /// </summary> - /// <param name="disposing">Whether to dispose.</param> - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _musicBrainzQuery.Dispose(); - } - } } 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 39cfd727f3..f448e6b20c 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -1,37 +1,52 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; +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; using MetaBrainz.MusicBrainz; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Plugins.MusicBrainz; /// <summary> /// Plugin instance. /// </summary> -public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages +public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IHasEmbeddedImage, IDisposable { + private readonly ILogger<Plugin> _logger; + private readonly Lock _queryLock = new(); + private Query _musicBrainzQuery; + private bool _disposed; + /// <summary> /// Initializes a new instance of the <see cref="Plugin"/> class. /// </summary> /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> /// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param> - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IApplicationHost applicationHost) + /// <param name="logger">Instance of the <see cref="ILogger{Plugin}"/> interface.</param> + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IApplicationHost applicationHost, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { Instance = this; + _logger = logger; // TODO: Change this to "JellyfinMusicBrainzPlugin" once we take it out of the server repo. Query.DefaultUserAgent.Add(new ProductInfoHeaderValue(applicationHost.Name.Replace(' ', '-'), applicationHost.ApplicationVersionString)); Query.DefaultUserAgent.Add(new ProductInfoHeaderValue($"({applicationHost.ApplicationUserAgentAddress})")); - Query.DelayBetweenRequests = Instance.Configuration.RateLimit; - Query.DefaultServer = Instance.Configuration.Server; + + ApplyServerConfig(Configuration); + Query.DelayBetweenRequests = Configuration.RateLimit; + _musicBrainzQuery = new Query(); + + ConfigurationChanged += OnConfigurationChanged; } /// <summary> @@ -53,6 +68,28 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages 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> + /// <remarks> + /// Always read this property anew before each request — the underlying instance is + /// replaced when the server URL changes. Old instances are intentionally left alive + /// so in-flight requests can finish; their unmanaged resources leak until GC. + /// </remarks> + public Query MusicBrainzQuery + { + get + { + lock (_queryLock) + { + return _musicBrainzQuery; + } + } + } + + /// <inheritdoc /> public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo @@ -61,4 +98,65 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" }; } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and managed resources. + /// </summary> + /// <param name="disposing">Whether to dispose managed resources.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + ConfigurationChanged -= OnConfigurationChanged; + lock (_queryLock) + { + _musicBrainzQuery.Dispose(); + } + } + + _disposed = true; + } + + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP003:Dispose previous before re-assigning", Justification = "The previous Query may still be in use by in-flight async requests; disposing it would cause ObjectDisposedException. The orphan is intentionally left for GC.")] + private void OnConfigurationChanged(object? sender, BasePluginConfiguration e) + { + var configuration = (PluginConfiguration)e; + ApplyServerConfig(configuration); + Query.DelayBetweenRequests = configuration.RateLimit; + + lock (_queryLock) + { + _musicBrainzQuery = new Query(); + } + } + + private void ApplyServerConfig(PluginConfiguration configuration) + { + if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server)) + { + Query.DefaultServer = server.DnsSafeHost; + Query.DefaultPort = server.Port; + Query.DefaultUrlScheme = server.Scheme; + } + else + { + _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server"); + var defaultServer = new Uri(PluginConfiguration.DefaultServer); + Query.DefaultServer = defaultServer.Host; + Query.DefaultPort = defaultServer.Port; + Query.DefaultUrlScheme = defaultServer.Scheme; + } + } } 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 82c6e3011a..f562d64ddd 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -413,6 +413,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } item.Overview = result.Plot; + 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/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index f11b1d95aa..78405c21fc 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -77,5 +77,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets or sets a value indicating the still image size to fetch. /// </summary> public string? StillSize { get; set; } + + /// <summary> + /// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching. + /// </summary> + public int SimilarItemsCacheDays { get; set; } = 7; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 89d380ec1f..4048fc1655 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -44,6 +44,13 @@ <span>Hide crew members without profile images.</span> </label> </div> + <div class="verticalSection"> + <h2>Similar Items</h2> + <div class="inputContainer"> + <input is="emby-input" type="number" id="similarItemsCacheDays" pattern="[0-9]*" required min="0" max="365" label="Cache duration (days)" /> + <div class="fieldDescription">Number of days to cache similar item results from TMDb. Set to 0 to disable caching.</div> + </div> + </div> <div class="verticalSection verticalSection-extrabottompadding"> <h2>Image Scaling</h2> <div class="selectContainer"> @@ -161,6 +168,13 @@ cancelable: false })); + var similarItemsCacheDays = document.querySelector('#similarItemsCacheDays'); + similarItemsCacheDays.value = config.SimilarItemsCacheDays; + similarItemsCacheDays.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + pluginConfig = config; configureImageScaling(); }); @@ -179,6 +193,7 @@ config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value; config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked; config.HideMissingCrewMembers = document.querySelector('#hideMissingCrewMembers').checked; + config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10); config.PosterSize = document.querySelector('#selectPosterSize').value; config.BackdropSize = document.querySelector('#selectBackdropSize').value; config.LogoSize = document.querySelector('#selectLogoSize').value; 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/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index ff584ba1de..8811a1787a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -379,6 +379,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies movie.RemoteTrailers = trailers; } + if (!string.IsNullOrEmpty(movieResult.OriginalLanguage)) + { + movie.OriginalLanguage = movieResult.OriginalLanguage; + } + return metadataResult; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs new file mode 100644 index 0000000000..5206de78ce --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Threading; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; + +namespace MediaBrowser.Providers.Plugins.Tmdb.Movies; + +/// <summary> +/// TMDb-based similar items provider for movies. +/// </summary> +public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> +{ + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILogger<TmdbMovieSimilarProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbMovieSimilarProvider"/> class. + /// </summary> + /// <param name="tmdbClientManager">The TMDb client manager.</param> + /// <param name="logger">The logger.</param> + public TmdbMovieSimilarProvider(TmdbClientManager tmdbClientManager, ILogger<TmdbMovieSimilarProvider> logger) + { + _tmdbClientManager = tmdbClientManager; + _logger = logger; + } + + /// <inheritdoc/> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// <inheritdoc/> + public TimeSpan? CacheDuration + { + get + { + var days = Plugin.Instance?.Configuration.SimilarItemsCacheDays ?? 0; + return days > 0 ? TimeSpan.FromDays(days) : null; + } + } + + /// <inheritdoc/> + public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + Movie item, + SimilarItemsQuery query, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (!item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbIdStr) || !int.TryParse(tmdbIdStr, CultureInfo.InvariantCulture, out var tmdbId)) + { + yield break; + } + + var providerName = MetadataProvider.Tmdb.ToString(); + var page = 0; + var totalPages = 1; + + while (page <= totalPages && !cancellationToken.IsCancellationRequested) + { + IReadOnlyList<TMDbLib.Objects.Search.SearchMovie> pageResults; + try + { + (pageResults, totalPages) = await _tmdbClientManager + .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page); + yield break; + } + + if (pageResults.Count == 0) + { + yield break; + } + + foreach (var similar in pageResults) + { + yield return new SimilarItemReference + { + ProviderName = providerName, + ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + }; + } + + page++; + } + } +} 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/TmdbEpisodeExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs new file mode 100644 index 0000000000..8d9d2d354b --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs @@ -0,0 +1,25 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// External id for a TMDb episode. + /// </summary> + public class TmdbEpisodeExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => TmdbUtils.ProviderName; + + /// <inheritdoc /> + public string Key => MetadataProvider.Tmdb.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Episode; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Episode; + } +} 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/TmdbSeasonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs new file mode 100644 index 0000000000..8191446363 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs @@ -0,0 +1,25 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// External id for a TMDb season. + /// </summary> + public class TmdbSeasonExternalId : IExternalId + { + /// <inheritdoc /> + public string ProviderName => TmdbUtils.ProviderName; + + /// <inheritdoc /> + public string Key => MetadataProvider.Tmdb.ToString(); + + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Season; + + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Season; + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs index 840cec9841..477bcc6f0c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs @@ -20,9 +20,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV public ExternalIdMediaType? Type => ExternalIdMediaType.Series; /// <inheritdoc /> - public bool Supports(IHasProviderIds item) - { - return item is Series; - } + public bool Supports(IHasProviderIds item) => item is Series; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 7e36c1e204..b44e54f888 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(); @@ -329,6 +338,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } + if (!string.IsNullOrEmpty(seriesResult.OriginalLanguage)) + { + series.OriginalLanguage = seriesResult.OriginalLanguage; + } + return series; } @@ -412,6 +426,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/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs new file mode 100644 index 0000000000..c85718b993 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Threading; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV; + +/// <summary> +/// TMDb-based similar items provider for TV series. +/// </summary> +public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> +{ + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILogger<TmdbSeriesSimilarProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbSeriesSimilarProvider"/> class. + /// </summary> + /// <param name="tmdbClientManager">The TMDb client manager.</param> + /// <param name="logger">The logger.</param> + public TmdbSeriesSimilarProvider(TmdbClientManager tmdbClientManager, ILogger<TmdbSeriesSimilarProvider> logger) + { + _tmdbClientManager = tmdbClientManager; + _logger = logger; + } + + /// <inheritdoc/> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// <inheritdoc/> + public TimeSpan? CacheDuration + { + get + { + var days = Plugin.Instance?.Configuration.SimilarItemsCacheDays ?? 0; + return days > 0 ? TimeSpan.FromDays(days) : null; + } + } + + /// <inheritdoc/> + public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + Series item, + SimilarItemsQuery query, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (!item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbIdStr) || !int.TryParse(tmdbIdStr, CultureInfo.InvariantCulture, out var tmdbId)) + { + yield break; + } + + var providerName = MetadataProvider.Tmdb.ToString(); + var page = 1; + var totalPages = 1; + + while (page <= totalPages && !cancellationToken.IsCancellationRequested) + { + IReadOnlyList<TMDbLib.Objects.Search.SearchTv> pageResults; + try + { + (pageResults, totalPages) = await _tmdbClientManager + .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); + yield break; + } + + if (pageResults.Count == 0) + { + yield break; + } + + foreach (var similar in pageResults) + { + yield return new SimilarItemReference + { + ProviderName = providerName, + ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + }; + } + + page++; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 274db347ba..174f1546a7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -505,6 +505,54 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Gets a single page of similar movies for a movie from the TMDb API. + /// </summary> + /// <param name="tmdbId">The TMDb id of the movie.</param> + /// <param name="page">The page number to fetch (1-based).</param> + /// <param name="language">The language for results.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + { + await EnsureClientConfigAsync().ConfigureAwait(false); + + var searchResults = await _tmDbClient + .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken) + .ConfigureAwait(false); + + if (searchResults?.Results is null || searchResults.Results.Count == 0) + { + return ([], 0); + } + + return (searchResults.Results, searchResults.TotalPages); + } + + /// <summary> + /// Gets a single page of similar TV shows for a series from the TMDb API. + /// </summary> + /// <param name="tmdbId">The TMDb id of the TV show.</param> + /// <param name="page">The page number to fetch (1-based).</param> + /// <param name="language">The language for results.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + { + await EnsureClientConfigAsync().ConfigureAwait(false); + + var searchResults = await _tmDbClient + .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken) + .ConfigureAwait(false); + + if (searchResults?.Results is null || searchResults.Results.Count == 0) + { + return ([], 0); + } + + return (searchResults.Results, searchResults.TotalPages); + } + + /// <summary> /// Handles bad path checking and builds the absolute url. /// </summary> /// <param name="size">The image size to fetch.</param> 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/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index a78ec995cf..c3458d4b2a 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -228,10 +228,11 @@ namespace MediaBrowser.Providers.Subtitles var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName)); savePaths.Add(mediaFolderPath); } - - var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); - - savePaths.Add(internalPath); + else + { + var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); + savePaths.Add(internalPath); + } await TrySaveToFiles(memoryStream, savePaths, video, response.Format.ToLowerInvariant()).ConfigureAwait(false); } 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/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 3f83f1d829..d3f0bfb5d4 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -543,6 +543,16 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "ratings": FetchFromRatingsNode(reader, item); break; + // For NFO files that have a separate community rating tag instead of using the ratings node with a name, or standard rating tag + case "communityrating": + var communityRatingText = reader.ReadElementContentAsString().Replace(',', '.'); + if (float.TryParse(communityRatingText, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var communityRatingValue) + && communityRatingValue >= 0 && communityRatingValue <= 10) + { + item.CommunityRating = communityRatingValue; + } + + break; case "aired": case "formed": case "premiered": 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 4ca3aa9ef5..78907a5e68 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -67,6 +67,7 @@ namespace MediaBrowser.XbmcMetadata.Savers "id", "credits", "originaltitle", + "originallanguage", "watched", "playcount", "lastplayed", @@ -197,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); @@ -221,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) @@ -376,6 +385,11 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("default", stream.IsDefault.ToString(CultureInfo.InvariantCulture)); writer.WriteElementString("forced", stream.IsForced.ToString(CultureInfo.InvariantCulture)); + if (stream.IsOriginal) + { + writer.WriteElementString("original", stream.IsOriginal.ToString(CultureInfo.InvariantCulture)); + } + if (stream.Type == MediaStreamType.Video) { var runtimeTicks = item.RunTimeTicks; @@ -484,6 +498,11 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("originaltitle", item.OriginalTitle); } + if (!string.IsNullOrWhiteSpace(item.OriginalLanguage)) + { + writer.WriteElementString("originallanguage", item.OriginalLanguage); + } + var people = libraryManager.GetPeople(item); var directors = people @@ -76,7 +76,7 @@ These instructions will help you get set up with a local development environment ### Prerequisites -Before the project can be built, you must first install the [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system. +Before the project can be built, you must first install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet) on your system. Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 6 development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2022) and [Visual Studio Code](https://code.visualstudio.com/Download). @@ -195,7 +195,5 @@ Since this is a common scenario, there is also a separate launch profile defined This project is supported by: <br/> <br/> -<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a> - <a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a> </p> diff --git a/SharedVersion.cs b/SharedVersion.cs index 3b394d28b2..1d4a368aa2 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("10.12.0")] -[assembly: AssemblyFileVersion("10.12.0")] +[assembly: AssemblyVersion("12.0.0")] +[assembly: AssemblyFileVersion("12.0.0")] diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 43e6a8bc00..88a2c684ff 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -111,7 +111,9 @@ public static class DescendantQueryHelper private static HashSet<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos - .Where(ms => ms.StreamType == criteria.StreamType && ms.Language == criteria.Language); + .Where(ms => ms.StreamType == criteria.StreamType + && (criteria.Language.Contains(ms.Language) + || (criteria.Language.Contains("und") && string.IsNullOrEmpty(ms.Language)))); // und = undetermined if (criteria.IsExternal.HasValue) { diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs index 76c847e5f0..6a6c8e1a6a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs @@ -96,6 +96,8 @@ public class BaseItemEntity public string? OriginalTitle { get; set; } + public string? OriginalLanguage { get; set; } + public Guid? PrimaryVersionId { get; set; } public DateTime? DateLastMediaAdded { get; set; } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs index e5cbab7e45..3e2e0bb7ae 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs @@ -63,6 +63,16 @@ namespace Jellyfin.Database.Implementations.Entities.Libraries public string? OriginalTitle { get; set; } /// <summary> + /// Gets or sets the original language. + /// </summary> + /// <remarks> + /// Max length = 1024. + /// </remarks> + [MaxLength(1024)] + [StringLength(1024)] + public string? OriginalLanguage { get; set; } + + /// <summary> /// Gets or sets the sort title. /// </summary> /// <remarks> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs index b80b764ba3..6953f9c859 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs @@ -40,6 +40,8 @@ public class MediaStreamInfo public bool IsExternal { get; set; } + public bool IsOriginal { get; set; } + public int? Height { get; set; } public int? Width { get; set; } 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/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs index f6fce7279a..b0c12bf592 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs @@ -273,6 +273,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog }).ConfigureAwait(false); return result; } + catch (DbUpdateConcurrencyException) + { + // a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat. + throw; + } catch (Exception e) { logger.LogError(e, "Error trying to save changes."); @@ -294,6 +299,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog }); return result; } + catch (DbUpdateConcurrencyException) + { + // a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat. + throw; + } catch (Exception e) { logger.LogError(e, "Error trying to save changes."); 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/MatchCriteria/HasMediaStreamType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs index 68f2ca2786..c1f6ab16a9 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs @@ -1,3 +1,6 @@ +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +using System.Collections.Generic; using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Database.Implementations.MatchCriteria; @@ -6,9 +9,23 @@ namespace Jellyfin.Database.Implementations.MatchCriteria; /// Matches folders containing descendants with a specific media stream type and language. /// </summary> /// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param> -/// <param name="Language">The language to match.</param> +/// <param name="Language">List of languages to match.</param> /// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param> public sealed record HasMediaStreamType( MediaStreamTypeEntity StreamType, - string Language, - bool? IsExternal = null) : FolderMatchCriteria; + IReadOnlyCollection<string> Language, + bool? IsExternal = null) : FolderMatchCriteria +{ + /// <summary> + /// Initializes a new instance of the <see cref="HasMediaStreamType"/> class. + /// </summary> + /// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param> + /// <param name="Language">The language to match.</param> + /// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param> + public HasMediaStreamType( + MediaStreamTypeEntity StreamType, + string Language, + bool? IsExternal = null) : this(StreamType, [Language], IsExternal) + { + } +} 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/20260522092303_AddNormalizedUsername.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs new file mode 100644 index 0000000000..63f858bc98 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260522092303_AddNormalizedUsername.Designer.cs @@ -0,0 +1,1804 @@ +// <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("20260522092303_AddNormalizedUsername")] + partial class AddNormalizedUsername + { + /// <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("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/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 2c74d47edc..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 => { @@ -264,6 +264,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<string>("OfficialRating") .HasColumnType("TEXT"); + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + b.Property<string>("OriginalTitle") .HasColumnType("TEXT"); @@ -955,6 +958,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<bool?>("IsInterlaced") .HasColumnType("INTEGER"); + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + b.Property<string>("KeyFrames") .HasColumnType("TEXT"); @@ -1342,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"); @@ -1384,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/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 9a7cf4aabe..5518d9b954 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -15,7 +15,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Extensions</PackageId> - <VersionPrefix>10.12.0</VersionPrefix> + <VersionPrefix>12.0.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> 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/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs index 71e46764ad..bb4238a2ac 100644 --- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs @@ -40,10 +40,10 @@ namespace Jellyfin.LiveTv.Channels } /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TasksRefreshChannels"); + public string Name => _localization.GetLocalizedString("TaskRefreshChannels"); /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TasksRefreshChannelsDescription"); + public string Description => _localization.GetLocalizedString("TaskRefreshChannelsDescription"); /// <inheritdoc /> public string Category => _localization.GetLocalizedString("TasksChannelsCategory"); diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..b8545cbb64 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using Jellyfin.LiveTv; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; @@ -448,18 +449,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) 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..d456bea469 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -684,27 +684,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 +748,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..7088d1f0bf 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; @@ -180,6 +181,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 +208,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/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/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 1d18ade9dc..173d3c3e8e 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -178,6 +178,11 @@ namespace Jellyfin.LiveTv { var program = _libraryManager.GetItemById(id); + if (program is null) + { + return null; + } + var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); var list = new List<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> @@ -1204,7 +1209,7 @@ namespace Jellyfin.LiveTv { Services = services, IsEnabled = services.Length > 0, - EnabledUsers = _userManager.Users + EnabledUsers = _userManager.GetUsers() .Where(IsLiveTvEnabled) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .ToArray() @@ -1220,7 +1225,7 @@ namespace Jellyfin.LiveTv public IEnumerable<User> GetEnabledUsers() { - return _userManager.Users + return _userManager.GetUsers() .Where(IsLiveTvEnabled); } diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs index a5d186ce18..4b0f63b041 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs @@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Recordings private async Task SendMessage(SessionMessageType name, TimerEventInfo info) { - var users = _userManager.Users + var users = _userManager.GetUsers() .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) .Select(i => i.Id) .ToList(); 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 6a8a91fa51..4559f68ce8 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -316,7 +316,7 @@ public class NetworkManager : INetworkManager, IDisposable var subnets = config.LocalNetworkSubnets; // If no LAN addresses are specified, all private subnets and Loopback are deemed to be the LAN - if (!NetworkUtils.TryParseToSubnets(subnets, out var lanSubnets, false) || lanSubnets.Count == 0) + if (!NetworkUtils.TryParseToSubnets(subnets, out var lanSubnets, false, _logger) || lanSubnets.Count == 0) { _logger.LogDebug("Using LAN interface addresses as user provided no LAN details."); @@ -343,7 +343,7 @@ public class NetworkManager : INetworkManager, IDisposable _lanSubnets = lanSubnets.Select(x => x.Subnet).ToArray(); } - _excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true) + _excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true, _logger) ? excludedSubnets.Select(x => x.Subnet).ToArray() : Array.Empty<IPNetwork>(); } @@ -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/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.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/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 3369af0e84..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); @@ -105,10 +146,12 @@ namespace Jellyfin.MediaEncoding.Tests.Probing var audio1 = res.MediaStreams[1]; Assert.Equal("eac3", audio1.Codec); + Assert.True(audio1.IsOriginal); Assert.Equal(AudioSpatialFormat.DolbyAtmos, audio1.AudioSpatialFormat); var audio2 = res.MediaStreams[2]; Assert.Equal("dts", audio2.Codec); + Assert.False(audio2.IsOriginal); Assert.Equal(AudioSpatialFormat.DTSX, audio2.AudioSpatialFormat); Assert.Empty(res.Chapters); @@ -156,6 +199,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("aac", res.MediaStreams[1].Codec); Assert.Equal(7, res.MediaStreams[1].Channels); Assert.True(res.MediaStreams[1].IsDefault); + Assert.False(res.MediaStreams[1].IsOriginal); Assert.Equal("eng", res.MediaStreams[1].Language); Assert.Equal("Surround 6.1", res.MediaStreams[1].Title); @@ -319,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/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/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 8269ae58cd..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,7 +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.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)] @@ -252,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 @@ -286,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)] @@ -333,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 @@ -672,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.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json new file mode 100644 index 0000000000..341638bc52 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json @@ -0,0 +1,162 @@ +{ + "Name": "Jellyfin AndroidTV-ExoPlayer", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "Audio,Photo,Video", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxStreamingBitrate": 120000000, + "MaxStaticBitrate": 100000000, + "MusicStreamingTranscodingBitrate": 192000, + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "DirectPlayProfiles": [ + { + "Container": "m4v,mov,xvid,vob,mkv,wmv,asf,ogm,ogv,mp4,webm", + "AudioCodec": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw", + "VideoCodec": "h264,hevc,vp8,vp9,mpeg,mpeg2video", + "Type": "Video", + "$type": "DirectPlayProfile" + }, + { + "Container": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw,,pa,flac,wav,wma,ogg,oga,webma,ape,opus", + "Type": "Audio", + "$type": "DirectPlayProfile" + }, + { + "Container": "jpg,jpeg,png,gif,web", + "Type": "Photo", + "$type": "DirectPlayProfile" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "EqualsAny", + "Property": "VideoProfile", + "Value": "main|main 10", + "IsRequired": false, + "$type": "ProfileCondition" + }, + { + "Condition": "LessThanEqual", + "Property": "VideoLevel", + "Value": "51", + "IsRequired": false, + "$type": "ProfileCondition" + }, + { + "Condition": "Equals", + "Property": "VideoRotation", + "Value": "0", + "IsRequired": false, + "$type": "ProfileCondition" + } + ], + "Codec": "hevc", + "$type": "CodecProfile" + } + ], + "TranscodingProfiles": [ + { + "Container": "ts", + "Type": "Video", + "VideoCodec": "h264", + "AudioCodec": "aac,mp3", + "Protocol": "hls", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MinSegments": 0, + "SegmentLength": 0, + "$type": "TranscodingProfile" + }, + { + "Container": "mp3", + "Type": "Audio", + "AudioCodec": "mp3", + "Protocol": "http", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MinSegments": 0, + "SegmentLength": 0, + "$type": "TranscodingProfile" + } + ], + "SubtitleProfiles": [ + { + "Format": "srt", + "Method": "Embed", + "$type": "SubtitleProfile" + }, + { + "Format": "srt", + "Method": "External", + "$type": "SubtitleProfile" + }, + { + "Format": "subrip", + "Method": "Embed", + "$type": "SubtitleProfile" + }, + { + "Format": "subrip", + "Method": "External", + "$type": "SubtitleProfile" + }, + { + "Format": "ass", + "Method": "Encode", + "$type": "SubtitleProfile" + }, + { + "Format": "ssa", + "Method": "Encode", + "$type": "SubtitleProfile" + }, + { + "Format": "pgs", + "Method": "Encode", + "$type": "SubtitleProfile" + }, + { + "Format": "pgssub", + "Method": "Encode", + "$type": "SubtitleProfile" + }, + { + "Format": "dvdsub", + "Method": "Encode", + "$type": "SubtitleProfile" + }, + { + "Format": "vtt", + "Method": "Embed", + "$type": "SubtitleProfile" + }, + { + "Format": "sub", + "Method": "Embed", + "$type": "SubtitleProfile" + }, + { + "Format": "idx", + "Method": "Embed", + "$type": "SubtitleProfile" + } + ], + "$type": "DeviceProfile" +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json new file mode 100644 index 0000000000..393b10171d --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json @@ -0,0 +1,56 @@ +{ + "Id": "b7a9e2d4c815f36b0d9241a7e58c3f42", + "Path": "/Media/MyVideo-1080p.mp4", + "Container": "mov,mp4,m4a,3gp,3g2,mj2", + "Size": 1421636271, + "Name": "MyVideo-1080p", + "ETag": "d8e2a1b5c4f907e8a1d2b3c4e5f6a7b8", + "RunTimeTicks": 25801230336, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "hevc", + "CodecTag": "hvc1", + "Language": "eng", + "TimeBase": "1/11988", + "VideoRange": "SDR", + "DisplayTitle": "1080p HEVC SDR", + "NalLengthSize": "0", + "BitRate": 4014613, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "Height": 1080, + "Width": 1920, + "AverageFrameRate": 23.976, + "RealFrameRate": 23.976, + "Profile": "Main", + "Type": 1, + "AspectRatio": "16:9", + "PixelFormat": "yuv420p", + "Level": 50, + "Rotation": 180 + }, + { + "Codec": "aac", + "CodecTag": "mp4a", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - AAC - Stereo - Default", + "ChannelLayout": "stereo", + "BitRate": 125427, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": true, + "Profile": "LC", + "Index": 1, + "Score": 203 + } + ], + "Bitrate": 4331578, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 2 +} 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.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 2fb45600b1..b29c64f50d 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; using Xunit; namespace Jellyfin.Naming.Tests.Video @@ -10,6 +12,12 @@ namespace Jellyfin.Naming.Tests.Video public class MultiVersionTests { private readonly NamingOptions _namingOptions = new NamingOptions(); + private readonly VideoListResolver _videoListResolver; + + public MultiVersionTests() + { + _videoListResolver = new VideoListResolver(_namingOptions); + } [Fact] public void TestMultiEdition1() @@ -22,9 +30,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result, v => v.ExtraType is null); Assert.Single(result, v => v.ExtraType is not null); @@ -41,9 +48,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result, v => v.ExtraType is null); Assert.Single(result, v => v.ExtraType is not null); @@ -59,9 +65,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -81,9 +86,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/M/Movie 7.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(7, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -104,9 +108,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie/Movie-8.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal(7, result[0].AlternateVersions.Count); @@ -128,9 +131,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Mo/Movie 9.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(9, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -148,9 +150,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie/Movie 5.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -170,9 +171,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man (2011).mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -192,19 +192,18 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man[test].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path); Assert.Equal(6, result[0].AlternateVersions.Count); - Assert.Equal("/movies/Iron Man/Iron Man-720p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/Iron Man/Iron Man-3d.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/Iron Man/Iron Man-3d-hsbs.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/Iron Man/Iron Man-bluray.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/Iron Man/Iron Man-test.mkv", result[0].AlternateVersions[4].Path); - Assert.Equal("/movies/Iron Man/Iron Man[test].mkv", result[0].AlternateVersions[5].Path); + Assert.Equal("/movies/Iron Man/Iron Man-720p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-3d.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-3d-hsbs.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-bluray.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-test.mkv", result[0].AlternateVersions[4].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man[test].mkv", result[0].AlternateVersions[5].Files[0].Path); } [Fact] @@ -221,19 +220,18 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man [test].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path); Assert.Equal(6, result[0].AlternateVersions.Count); - Assert.Equal("/movies/Iron Man/Iron Man - 720p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/Iron Man/Iron Man - 3d.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/Iron Man/Iron Man - 3d-hsbs.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/Iron Man/Iron Man - bluray.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/Iron Man/Iron Man - test.mkv", result[0].AlternateVersions[4].Path); - Assert.Equal("/movies/Iron Man/Iron Man [test].mkv", result[0].AlternateVersions[5].Path); + Assert.Equal("/movies/Iron Man/Iron Man - 720p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - 3d.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - 3d-hsbs.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - bluray.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - test.mkv", result[0].AlternateVersions[4].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man [test].mkv", result[0].AlternateVersions[5].Files[0].Path); } [Fact] @@ -245,9 +243,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man - C (2007).mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -266,17 +263,16 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man_3d.hsbs.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal(6, result[0].AlternateVersions.Count); // Verify 3D recognition is preserved on alternate versions - var hsbs = result[0].AlternateVersions.First(v => v.Path.Contains("3d-hsbs", StringComparison.Ordinal)); - Assert.True(hsbs.Is3D); - Assert.Equal("hsbs", hsbs.Format3D); + var hsbs = result[0].AlternateVersions.First(v => v.Files[0].Path.Contains("3d-hsbs", StringComparison.Ordinal)); + Assert.True(hsbs.Files[0].Is3D); + Assert.Equal("hsbs", hsbs.Files[0].Format3D); } [Fact] @@ -293,9 +289,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man (2011).mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -310,9 +305,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -327,9 +321,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -348,18 +341,17 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path); Assert.Equal(5, result[0].AlternateVersions.Count); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[4].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[4].Files[0].Path); } [Fact] @@ -381,24 +373,23 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path); Assert.Equal(11, result[0].AlternateVersions.Count); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Directors Cut.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", result[0].AlternateVersions[4].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", result[0].AlternateVersions[5].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", result[0].AlternateVersions[6].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[7].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", result[0].AlternateVersions[8].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[9].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[10].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Directors Cut.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", result[0].AlternateVersions[4].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", result[0].AlternateVersions[5].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", result[0].AlternateVersions[6].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[7].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", result[0].AlternateVersions[8].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[9].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[10].Files[0].Path); } [Fact] @@ -410,9 +401,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -427,9 +417,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -437,7 +426,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestEmptyList() { - var result = VideoListResolver.Resolve(new List<VideoFileInfo>(), _namingOptions).ToList(); + var result = _videoListResolver.Resolve(new List<VideoFileInfo>()).ToList(); Assert.Empty(result); } @@ -451,9 +440,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie (2020)/Movie (2020)_1080p.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -468,11 +456,678 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie (2020)/Movie (2020).1080p.mkv" }; - var result = VideoListResolver.Resolve( + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + } + + // Episode multi-version tests + + [Fact] + public void TestMultiVersionEpisodeInOwnFolder() + { + // Two versions of S01E01 in their own subfolder should merge + var files = new[] + { + "/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - 1080p.mkv", + "/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + // 1080p should be primary (higher resolution) + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeMixedSeasonFolder() + { + // Multiple episodes in season folder, some with versions + var files = new[] + { + "/TV/Dexter/Season 1/Dexter - S01E01 - 1080p.mkv", + "/TV/Dexter/Season 1/Dexter - S01E01 - 720p.mkv", + "/TV/Dexter/Season 1/Dexter - S01E02.mkv", + "/TV/Dexter/Season 1/Dexter - S01E03 - 1080p.mkv", + "/TV/Dexter/Season 1/Dexter - S01E03 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(3, result.Count); + + // S01E01 - should have one alternate version + var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal)); + Assert.NotNull(e01); + Assert.Single(e01!.AlternateVersions); + Assert.Contains("1080p", e01.Files[0].Path, StringComparison.Ordinal); + + // S01E02 - standalone, no alternates + var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal)); + Assert.NotNull(e02); + Assert.Empty(e02!.AlternateVersions); + + // S01E03 - should have one alternate version + var e03 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E03", StringComparison.Ordinal)); + Assert.NotNull(e03); + Assert.Single(e03!.AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeDontCollapse() + { + // Different episodes should NOT collapse into versions + var files = new[] + { + "/TV/Dexter/Season 1/Dexter - S01E01.mkv", + "/TV/Dexter/Season 1/Dexter - S01E02.mkv", + "/TV/Dexter/Season 1/Dexter - S01E03.mkv", + "/TV/Dexter/Season 1/Dexter - S01E04.mkv", + "/TV/Dexter/Season 1/Dexter - S01E05.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(5, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeWithVersionSuffix() + { + // Episodes with named versions (like Aired/Uncensored) + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Aired.mkv", + "/TV/Show/Season 1/Show - S01E01 - Uncensored.mkv", + "/TV/Show/Season 1/Show - S01E02 - Aired.mkv", + "/TV/Show/Season 1/Show - S01E02 - Uncensored.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Single(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeFourVersions() + { + // Four versions of the same episode + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - VersionA.mkv", + "/TV/Show/Season 1/Show - S01E01 - VersionB.mkv", + "/TV/Show/Season 1/Show - S01E01 - VersionC.mkv", + "/TV/Show/Season 1/Show - S01E01 - VersionD.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(3, result[0].AlternateVersions.Count); + } + + [Fact] + public void TestMultiVersionEpisodeWithResolutions() + { + // Resolution sorting should work for episodes too + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", + "/TV/Show/Season 1/Show - S01E01 - 2160p.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].AlternateVersions.Count); + // Primary should be 2160p (highest resolution) + Assert.Contains("2160p", result[0].Files[0].Path, StringComparison.Ordinal); + // Next should be 1080p, then 720p + Assert.Contains("1080p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[1].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeDifferentSeasons() + { + // Same episode number but different seasons should NOT group + var files = new[] + { + "/TV/Show/Show - S01E01.mkv", + "/TV/Show/Show - S02E01.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeDisabledByDefault() + { + // Without collectionType: CollectionType.tvshows, episodes should NOT group + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + // Without the tvshows collection type, these fall through the movie path + // (folder-name eligibility fails) and are treated as separate items. + Assert.Equal(2, result.Count); + } + + [Fact] + public void TestMultiVersionEpisodeSameNumberDifferentTitle() + { + // Two files parse to the same S01E01 but carry distinct episode titles. + // Current behavior: they are grouped as alternate versions because + // grouping keys only on season + episode number, not on episode title. + // This documents the trade-off: users with mis-numbered episodes will + // see one of the files collapsed into AlternateVersions of the other. + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot.mkv", + "/TV/Show/Season 1/Show - S01E01 - Completely Different Title.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeWithTitle() + { + // Episodes with an episode title AND a version suffix should group + var files = new[] + { + "/TV/Show/Show - S01E01/Show - S01E01 - Episode Title - 1080p.mkv", + "/TV/Show/Show - S01E01/Show - S01E01 - Episode Title - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithTitleMixedFolder() + { + // Multiple different episodes with titles and resolution variants in a season folder + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv", + "/TV/Show/Season 1/Show - S01E02 - Second Episode - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E02 - Second Episode - 720p.mkv", + "/TV/Show/Season 1/Show - S01E03 - Third Episode.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(3, result.Count); + + var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal)); + Assert.NotNull(e01); + Assert.Single(e01!.AlternateVersions); + + var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal)); + Assert.NotNull(e02); + Assert.Single(e02!.AlternateVersions); + + var e03 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E03", StringComparison.Ordinal)); + Assert.NotNull(e03); + Assert.Empty(e03!.AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeInSeasonSubfolder() + { + // Two versions of S01E01 in their own subfolder under a season folder + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01/Show - S01E01 - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E01/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithTitleAndVersionSuffix() + { + // Episodes with episode title AND a named version suffix + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - Aired.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - Uncensored.mkv", + "/TV/Show/Season 1/Show - S01E02 - The Getaway - Aired.mkv", + "/TV/Show/Season 1/Show - S01E02 - The Getaway - Uncensored.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Single(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsCd() + { + // Stacked episode (cd1/cd2) with higher resolution alongside a single-file lower-res version + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p cd1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p cd2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsDashPart() + { + // Stacked episode using "- part1" / "- part2" separator + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p - part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsPt() + { + // Stacked episode using "pt1" / "pt2" short form + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p.pt1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p.pt2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsAndTitle() + { + // Stacked episode with episode title in filename + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + // Primary should be the stacked 1080p version with 2 files + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsAndTitleDashSeparator() + { + // Stacked episode with episode title using "- part1" separator + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p - part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + // Primary should be the stacked 1080p version with 2 files + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsAndMultipleEpisodes() + { + // Stacked episode alongside single-file version, plus a different episode + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p cd1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p cd2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", + "/TV/Show/Season 1/Show - S01E02 - Other.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + + // S01E01: stacked (cd1+cd2) primary with 720p alternate + var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal)); + Assert.NotNull(e01); + Assert.Equal(2, e01!.Files.Count); + Assert.Single(e01.AlternateVersions); + + // S01E02: standalone + var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal)); + Assert.NotNull(e02); + Assert.Empty(e02!.AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodePartStackAlongsideSingleFileResolutions() + { + // A part-stacked episode (3 parts, no resolution suffix) alongside single-file 720p and 1080p versions. + // The multi-part stack is preferred as primary. + var files = new[] + { + "/TV/Show/Season 1/S01E01 - 720p.mkv", + "/TV/Show/Season 1/S01E01 - 1080p.mkv", + "/TV/Show/Season 1/S01E01 - Part 1.mkv", + "/TV/Show/Season 1/S01E01 - Part 2.mkv", + "/TV/Show/Season 1/S01E01 - Part 3.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(3, result[0].Files.Count); + Assert.All(result[0].Files, f => Assert.Contains("Part", f.Path, StringComparison.Ordinal)); + Assert.Equal(2, result[0].AlternateVersions.Count); + Assert.Contains(result[0].AlternateVersions, f => f.Files[0].Path.Contains("1080p", StringComparison.Ordinal)); + Assert.Contains(result[0].AlternateVersions, f => f.Files[0].Path.Contains("720p", StringComparison.Ordinal)); + } + + [Fact] + public void TestMultiVersionEpisodeTwoPartStacks() + { + // Two part-suffixed stacks of the same episode at different resolutions. + // The 1080p stack is primary, the 720p stack is preserved as a multi-file alternate. + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p - part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p - part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + + Assert.Single(result[0].AlternateVersions); + var alt = result[0].AlternateVersions[0]; + Assert.Equal(2, alt.Files.Count); + Assert.All(alt.Files, f => Assert.Contains("720p", f.Path, StringComparison.Ordinal)); + } + + [Fact] + public void TestMultiVersionEpisodePartStackWithTrailer() + { + // A part-stacked multi-version episode alongside a trailer must not pull the trailer into the version group + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", + "/TV/Show/Season 1/Show - S01E01-trailer.mp4" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + + var episode = result.FirstOrDefault(r => r.ExtraType is null); + Assert.NotNull(episode); + Assert.Equal(2, episode!.Files.Count); + Assert.Single(episode.AlternateVersions); + Assert.Contains("720p", episode.AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + + var trailer = result.FirstOrDefault(r => r.ExtraType is not null); + Assert.NotNull(trailer); + Assert.Equal(ExtraType.Trailer, trailer!.ExtraType); + } + + [Fact] + public void TestMovieStackingWithPartNaming() + { + // Movie stacking with "part1"/"part2" naming + var files = new[] + { + "/movies/Movie/Movie part1.mkv", + "/movies/Movie/Movie part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithDashPartNaming() + { + // Movie stacking with "- part1" / "- part2" dash separator + var files = new[] + { + "/movies/Movie/Movie - part1.mkv", + "/movies/Movie/Movie - part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithPtNaming() + { + // Movie stacking with "pt1"/"pt2" short form + var files = new[] + { + "/movies/Movie/Movie.pt1.mkv", + "/movies/Movie/Movie.pt2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithHyphenNoSpaces() + { + // Movie stacking with hyphen directly adjacent to "part" (no spaces) + var files = new[] + { + "/movies/Movie/Movie-part1.mkv", + "/movies/Movie/Movie-part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithHyphenNoSpacesAndVersion() + { + // Movie stacking with hyphen-no-space separators plus a version alternate + var files = new[] + { + "/movies/Movie/Movie-1080p-part1.mkv", + "/movies/Movie/Movie-1080p-part2.mkv", + "/movies/Movie/Movie-720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + // Stacked 1080p (2 files) should be primary, 720p is alternate + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + } + + [Fact] + public void TestMovieMultiVersionWithStackedAlternate() + { + // Movie folder where the folder-named file is the primary (single file via primaryOverride) + // and an alternate version is itself a stack. The stacked alternate must keep all its files. + var files = new[] + { + "/movies/Inception (2010)/Inception (2010).mkv", + "/movies/Inception (2010)/Inception (2010) - 4k part1.mkv", + "/movies/Inception (2010)/Inception (2010) - 4k part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Single(result[0].Files); + Assert.Equal("/movies/Inception (2010)/Inception (2010).mkv", result[0].Files[0].Path); + + Assert.Single(result[0].AlternateVersions); + var stackedAlternate = result[0].AlternateVersions[0]; + Assert.Equal(2, stackedAlternate.Files.Count); + Assert.All(stackedAlternate.Files, f => Assert.Contains("4k part", f.Path, StringComparison.Ordinal)); + } + + [Fact] + public void TestEpisodeStackingWithHyphenNoSpaces() + { + // Episode stacking with hyphen-no-space separators plus version alternate + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01-1080p-cd1.mkv", + "/TV/Show/Season 1/Show - S01E01-1080p-cd2.mkv", + "/TV/Show/Season 1/Show - S01E01-720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + // Stacked 1080p (2 files) should be primary, 720p is alternate + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + } + + [Fact] + public void TestEpisodeStackingWithHyphenNoSpacesAndTitle() + { + // Episode stacking with title and hyphen-no-space separators + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot-1080p-part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot-1080p-part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot-720p.mkv" + }; + + var result = _videoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + collectionType: CollectionType.tvshows).ToList(); Assert.Single(result); + // Stacked 1080p (2 files) should be primary, 720p is alternate + Assert.Equal(2, result[0].Files.Count); Assert.Single(result[0].AlternateVersions); } } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index d3164ba9c9..53f16b92d6 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -10,6 +10,12 @@ namespace Jellyfin.Naming.Tests.Video public class VideoListResolverTests { private readonly NamingOptions _namingOptions = new NamingOptions(); + private readonly VideoListResolver _videoListResolver; + + public VideoListResolverTests() + { + _videoListResolver = new VideoListResolver(_namingOptions); + } [Fact] public void TestStackAndExtras() @@ -40,9 +46,8 @@ namespace Jellyfin.Naming.Tests.Video "WillyWonka-trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(11, result.Count); var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal)); @@ -74,9 +79,8 @@ namespace Jellyfin.Naming.Tests.Video "300.nfo" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -90,9 +94,8 @@ namespace Jellyfin.Naming.Tests.Video "300 - trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -108,9 +111,8 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -127,9 +129,8 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer2.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -147,9 +148,8 @@ namespace Jellyfin.Naming.Tests.Video "Looper.2012.bluray.720p.x264.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -166,9 +166,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Looper (2012)/Looper.bluray.720p.x264.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -188,9 +187,8 @@ namespace Jellyfin.Naming.Tests.Video "My video 5.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); } @@ -204,9 +202,8 @@ namespace Jellyfin.Naming.Tests.Video "M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -221,9 +218,8 @@ namespace Jellyfin.Naming.Tests.Video "My movie #2.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -239,9 +235,8 @@ namespace Jellyfin.Naming.Tests.Video "No (2012)-trailer.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -260,9 +255,8 @@ namespace Jellyfin.Naming.Tests.Video "/Movies/trailer.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(4, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -282,9 +276,8 @@ namespace Jellyfin.Naming.Tests.Video "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -297,9 +290,8 @@ namespace Jellyfin.Naming.Tests.Video "/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -312,9 +304,8 @@ namespace Jellyfin.Naming.Tests.Video "The Colony.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -328,9 +319,8 @@ namespace Jellyfin.Naming.Tests.Video "Four Sisters and a Wedding - B.avi" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); // The result should contain two individual movies // Version grouping should not work here, because the files are not in a directory with the name 'Four Sisters and a Wedding' @@ -346,9 +336,8 @@ namespace Jellyfin.Naming.Tests.Video "Four Rooms - A.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -362,9 +351,8 @@ namespace Jellyfin.Naming.Tests.Video "/Server/Despicable Me/trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -380,9 +368,8 @@ namespace Jellyfin.Naming.Tests.Video "/Server/Despicable Me/trailers/some title.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -398,9 +385,8 @@ namespace Jellyfin.Naming.Tests.Video "/Movies/Despicable Me/trailers/trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index b63009d6a5..1f523f7f21 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -7,6 +7,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Model.Net; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -94,10 +95,106 @@ namespace Jellyfin.Networking.Tests [InlineData("256.128.0.0.0.1")] [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")] [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")] + [InlineData("fd23:184f:2029:0100/56")] public static void TryParseInvalidIPStringsFalse(string address) => Assert.False(NetworkUtils.TryParseToSubnet(address, out _)); /// <summary> + /// Verifies that <see cref="NetworkUtils.TryParseToSubnets"/> emits a targeted warning + /// for IPv6 prefix-only notation and a generic warning for other malformed entries. + /// </summary> + [Fact] + public static void TryParseToSubnets_InvalidEntries_LogsWarnings() + { + var logger = new Mock<ILogger>(); + + var values = new[] { "10.0.0.0/8", "fd23:184f:2029:0100/56", "not-an-address" }; + Assert.True(NetworkUtils.TryParseToSubnets(values, out var result, false, logger.Object)); + Assert.NotNull(result); + Assert.Single(result); + + // IPv6 prefix-only notation should produce a specific, actionable warning. + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("IPv6 prefix-only", StringComparison.Ordinal) + && state.ToString()!.Contains("fd23:184f:2029:0100/56", StringComparison.Ordinal)), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Once); + + // Other malformed entries should still produce a generic warning. + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("not-an-address", StringComparison.Ordinal)), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Once); + } + + /// <summary> + /// Verifies that IPv4 entries whose '!' polarity doesn't match the requested pass are skipped silently, + /// not logged as invalid. Callers parse the same list twice (LAN and excluded) so the off-polarity + /// entries are expected, not erroneous. + /// </summary> + [Fact] + public static void TryParseToSubnets_PolarityMismatchIPv4_DoesNotWarn() + { + var logger = new Mock<ILogger>(); + var values = new[] { "127.0.0.0/8", "192.168.178.0/24", "!10.0.0.0/8" }; + + // Non-negated pass picks up the two non-'!' entries and ignores '!10.0.0.0/8' silently. + Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object)); + Assert.NotNull(lanResult); + Assert.Equal(2, lanResult.Count); + + // Negated pass picks up the single '!' entry and ignores the others silently. + Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object)); + Assert.NotNull(excludedResult); + Assert.Single(excludedResult); + + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + /// <summary> + /// Same as the IPv4 case but for IPv6 entries — makes sure the polarity pre-check works + /// for IPv6 CIDR notation (with '::') as well. + /// </summary> + [Fact] + public static void TryParseToSubnets_PolarityMismatchIPv6_DoesNotWarn() + { + var logger = new Mock<ILogger>(); + var values = new[] { "fd00::/8", "fe80::/10", "!fd12:3456:789a::/48" }; + + Assert.True(NetworkUtils.TryParseToSubnets(values, out var lanResult, false, logger.Object)); + Assert.NotNull(lanResult); + Assert.Equal(2, lanResult.Count); + + Assert.True(NetworkUtils.TryParseToSubnets(values, out var excludedResult, true, logger.Object)); + Assert.NotNull(excludedResult); + Assert.Single(excludedResult); + + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.IsAny<It.IsAnyType>(), + It.IsAny<Exception>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Never); + } + + /// <summary> /// Checks if IPv4 address is within a defined subnet. /// </summary> /// <param name="netMask">Network mask.</param> diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs new file mode 100644 index 0000000000..a9161a0402 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs @@ -0,0 +1,89 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.AudioDb; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + public sealed class AudioDbExternalUrlProviderTests + { + private readonly AudioDbAlbumExternalUrlProvider _albumProvider = new(); + private readonly AudioDbArtistExternalUrlProvider _artistProvider = new(); + + [Fact] + public void GetExternalUrls_MusicAlbumWithAudioDbAlbumId_ReturnsCorrectUrl() + { + var album = new MusicAlbum(); + album.SetProviderId(MetadataProvider.AudioDbAlbum, "12345"); + + var urls = _albumProvider.GetExternalUrls(album); + + Assert.Contains("https://www.theaudiodb.com/album/12345", urls); + } + + [Fact] + public void GetExternalUrls_MusicAlbumWithNoAudioDbAlbumId_ReturnsNoUrl() + { + var album = new MusicAlbum(); + + var urls = _albumProvider.GetExternalUrls(album); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonAlbumWithAudioDbAlbumId_ReturnsNoUrl() + { + var artist = new MusicArtist(); + artist.SetProviderId(MetadataProvider.AudioDbAlbum, "12345"); + + var urls = _albumProvider.GetExternalUrls(artist); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_MusicArtistWithAudioDbArtistId_ReturnsCorrectUrl() + { + var artist = new MusicArtist(); + artist.SetProviderId(MetadataProvider.AudioDbArtist, "67890"); + + var urls = _artistProvider.GetExternalUrls(artist); + + Assert.Contains("https://www.theaudiodb.com/artist/67890", urls); + } + + [Fact] + public void GetExternalUrls_PersonWithAudioDbArtistId_ReturnsCorrectUrl() + { + var person = new Person(); + person.SetProviderId(MetadataProvider.AudioDbArtist, "67890"); + + var urls = _artistProvider.GetExternalUrls(person); + + Assert.Contains("https://www.theaudiodb.com/artist/67890", urls); + } + + [Fact] + public void GetExternalUrls_MusicArtistWithNoAudioDbArtistId_ReturnsNoUrl() + { + var artist = new MusicArtist(); + + var urls = _artistProvider.GetExternalUrls(artist); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonArtistWithAudioDbArtistId_ReturnsNoUrl() + { + var album = new MusicAlbum(); + album.SetProviderId(MetadataProvider.AudioDbArtist, "67890"); + + var urls = _artistProvider.GetExternalUrls(album); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs new file mode 100644 index 0000000000..aaa500b762 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs @@ -0,0 +1,56 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Books.ComicVine; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + public sealed class ComicVineExternalUrlProviderTests + { + private readonly ComicVineExternalUrlProvider _provider = new(); + + [Fact] + public void GetExternalUrls_PersonWithComicVineId_ReturnsCorrectUrl() + { + var person = new Person(); + person.SetProviderId("ComicVine", "person/4005-1234"); + + var urls = _provider.GetExternalUrls(person); + + Assert.Contains("https://comicvine.gamespot.com/person/4005-1234", urls); + } + + [Fact] + public void GetExternalUrls_BookWithComicVineId_ReturnsCorrectUrl() + { + var book = new Book(); + book.SetProviderId("ComicVine", "issue/4000-5678"); + + var urls = _provider.GetExternalUrls(book); + + Assert.Contains("https://comicvine.gamespot.com/issue/4000-5678", urls); + } + + [Fact] + public void GetExternalUrls_PersonWithNoComicVineId_ReturnsNoUrl() + { + var person = new Person(); + + var urls = _provider.GetExternalUrls(person); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonSupportedItemWithComicVineId_ReturnsNoUrl() + { + var series = new Series(); + series.SetProviderId("ComicVine", "volume/4050-9999"); + + var urls = _provider.GetExternalUrls(series); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs new file mode 100644 index 0000000000..b9ce895dbc --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs @@ -0,0 +1,45 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Books.GoogleBooks; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + public sealed class GoogleBooksExternalUrlProviderTests + { + private readonly GoogleBooksExternalUrlProvider _provider = new(); + + [Fact] + public void GetExternalUrls_BookWithGoogleBooksId_ReturnsCorrectUrl() + { + var book = new Book(); + book.SetProviderId("GoogleBooks", "buc0AAAAMAAJ"); + + var urls = _provider.GetExternalUrls(book); + + Assert.Contains("https://books.google.com/books?id=buc0AAAAMAAJ", urls); + } + + [Fact] + public void GetExternalUrls_BookWithNoGoogleBooksId_ReturnsNoUrl() + { + var book = new Book(); + + var urls = _provider.GetExternalUrls(book); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonBookWithGoogleBooksId_ReturnsNoUrl() + { + var series = new Series(); + series.SetProviderId("GoogleBooks", "buc0AAAAMAAJ"); + + var urls = _provider.GetExternalUrls(series); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs new file mode 100644 index 0000000000..ed4a8e7478 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs @@ -0,0 +1,125 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Movies; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + // put tests that mock the static LibraryManager in the same collection to avoid test interference + [Collection("LibraryManagerTests")] + public sealed class ImdbExternalUrlProviderTests : IDisposable + { + private readonly ImdbExternalUrlProvider _provider = new(); + private readonly Mock<ILibraryManager> _libraryManagerMock = new(); + private readonly ILibraryManager? _previousLibraryManager; + + public ImdbExternalUrlProviderTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + BaseItem.LibraryManager = _libraryManagerMock.Object; + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager; + } + + [Fact] + public void GetExternalUrls_MovieWithImdbId_ReturnsCorrectUrl() + { + var movie = new Movie(); + movie.SetProviderId(MetadataProvider.Imdb, "tt1234567"); + + var urls = _provider.GetExternalUrls(movie); + + Assert.Contains("https://www.imdb.com/title/tt1234567", urls); + } + + [Fact] + public void GetExternalUrls_SeriesWithImdbId_ReturnsCorrectUrl() + { + var series = new Series(); + series.SetProviderId(MetadataProvider.Imdb, "tt7654321"); + + var urls = _provider.GetExternalUrls(series); + + Assert.Contains("https://www.imdb.com/title/tt7654321", urls); + } + + [Fact] + public void GetExternalUrls_EpisodeWithImdbId_ReturnsCorrectUrl() + { + var episode = new Episode(); + episode.SetProviderId(MetadataProvider.Imdb, "tt9999999"); + + var urls = _provider.GetExternalUrls(episode); + + Assert.Contains("https://www.imdb.com/title/tt9999999", urls); + } + + [Fact] + public void GetExternalUrls_SeasonWithSeriesImdbId_ReturnsSeasonEpisodesUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + series.SetProviderId(MetadataProvider.Imdb, "tt1234567"); + + var season = new Season { IndexNumber = 2, SeriesId = series.Id }; + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + + var urls = _provider.GetExternalUrls(season); + + Assert.Contains("https://www.imdb.com/title/tt1234567/episodes/?season=2", urls); + } + + [Fact] + public void GetExternalUrls_SeasonWithNoSeriesImdbId_ReturnsNoUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + var season = new Season { IndexNumber = 1, SeriesId = series.Id }; + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + + var urls = _provider.GetExternalUrls(season); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_SeasonWithNoIndexNumber_ReturnsNoUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + series.SetProviderId(MetadataProvider.Imdb, "tt1234567"); + var season = new Season { IndexNumber = null, SeriesId = series.Id }; + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + + var urls = _provider.GetExternalUrls(season); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_SeasonWithUnknownSeriesId_ReturnsNoUrl() + { + var season = new Season { IndexNumber = 1, SeriesId = Guid.NewGuid() }; + _libraryManagerMock.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null); + + var urls = _provider.GetExternalUrls(season); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_ItemWithNoImdbId_ReturnsNoUrl() + { + var movie = new Movie(); + + var urls = _provider.GetExternalUrls(movie); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs new file mode 100644 index 0000000000..228a9d2656 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs @@ -0,0 +1,45 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Books.Isbn; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + public sealed class IsbnExternalUrlProviderTests + { + private readonly IsbnExternalUrlProvider _provider = new(); + + [Fact] + public void GetExternalUrls_BookWithIsbnId_ReturnsCorrectUrl() + { + var book = new Book(); + book.SetProviderId("ISBN", "9780306406157"); + + var urls = _provider.GetExternalUrls(book); + + Assert.Contains("https://search.worldcat.org/search?q=bn:9780306406157", urls); + } + + [Fact] + public void GetExternalUrls_BookWithNoIsbnId_ReturnsNoUrl() + { + var book = new Book(); + + var urls = _provider.GetExternalUrls(book); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonBookWithIsbnId_ReturnsNoUrl() + { + var series = new Series(); + series.SetProviderId("ISBN", "9780306406157"); + + var urls = _provider.GetExternalUrls(series); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs new file mode 100644 index 0000000000..d35211f387 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Reflection; +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Providers.Plugins.MusicBrainz; +using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + public sealed class MusicBrainzExternalUrlProviderTests : IDisposable + { + private static readonly PropertyInfo _instanceProperty = + typeof(Plugin).GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)!; + + private static readonly MethodInfo _instanceSetter = + _instanceProperty.GetSetMethod(nonPublic: true)!; + + private readonly Plugin? _previousPlugin; + + public MusicBrainzExternalUrlProviderTests() + { + _previousPlugin = Plugin.Instance; + + var appPathsMock = new Mock<IApplicationPaths>(); + appPathsMock.Setup(p => p.PluginsPath).Returns(System.IO.Path.GetTempPath()); + appPathsMock.Setup(p => p.PluginConfigurationsPath).Returns(System.IO.Path.GetTempPath()); + + var xmlSerializerMock = new Mock<IXmlSerializer>(); + xmlSerializerMock + .Setup(s => s.DeserializeFromFile(typeof(PluginConfiguration), It.IsAny<string>())) + .Returns(new PluginConfiguration()); + + var appHostMock = new Mock<IApplicationHost>(); + appHostMock.Setup(h => h.Name).Returns("Jellyfin"); + appHostMock.Setup(h => h.ApplicationVersionString).Returns("1.0.0"); + appHostMock.Setup(h => h.ApplicationUserAgentAddress).Returns("localhost"); + + _ = new Plugin(appPathsMock.Object, xmlSerializerMock.Object, appHostMock.Object, NullLogger<Plugin>.Instance); + } + + public void Dispose() + { + _instanceSetter.Invoke(null, new object?[] { _previousPlugin }); + } + + [Fact] + public void GetExternalUrls_MusicAlbumWithMusicBrainzAlbumId_ReturnsCorrectUrl() + { + var album = new MusicAlbum(); + album.SetProviderId(MetadataProvider.MusicBrainzAlbum, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(album); + + Assert.Contains(PluginConfiguration.DefaultServer + "/release/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls); + } + + [Fact] + public void GetExternalUrls_MusicAlbumWithNoMusicBrainzAlbumId_ReturnsNoUrl() + { + var album = new MusicAlbum(); + + var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(album); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonAlbumWithMusicBrainzAlbumId_ReturnsNoUrl() + { + var artist = new MusicArtist(); + artist.SetProviderId(MetadataProvider.MusicBrainzAlbum, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(artist); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_MusicAlbumWithMusicBrainzAlbumArtistId_ReturnsCorrectUrl() + { + var album = new MusicAlbum(); + album.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzAlbumArtistExternalUrlProvider().GetExternalUrls(album); + + Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls); + } + + [Fact] + public void GetExternalUrls_MusicAlbumWithNoMusicBrainzAlbumArtistId_ReturnsNoUrl() + { + var album = new MusicAlbum(); + + var urls = new MusicBrainzAlbumArtistExternalUrlProvider().GetExternalUrls(album); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_MusicArtistWithMusicBrainzArtistId_ReturnsCorrectUrl() + { + var artist = new MusicArtist(); + artist.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(artist); + + Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls); + } + + [Fact] + public void GetExternalUrls_PersonWithMusicBrainzArtistId_ReturnsCorrectUrl() + { + var person = new Person(); + person.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(person); + + Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls); + } + + [Fact] + public void GetExternalUrls_MusicArtistWithNoMusicBrainzArtistId_ReturnsNoUrl() + { + var artist = new MusicArtist(); + + var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(artist); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonArtistWithMusicBrainzArtistId_ReturnsNoUrl() + { + var album = new MusicAlbum(); + album.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(album); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_MusicAlbumWithMusicBrainzReleaseGroupId_ReturnsCorrectUrl() + { + var album = new MusicAlbum(); + album.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzReleaseGroupExternalUrlProvider().GetExternalUrls(album); + + Assert.Contains(PluginConfiguration.DefaultServer + "/release-group/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls); + } + + [Fact] + public void GetExternalUrls_MusicAlbumWithNoMusicBrainzReleaseGroupId_ReturnsNoUrl() + { + var album = new MusicAlbum(); + + var urls = new MusicBrainzReleaseGroupExternalUrlProvider().GetExternalUrls(album); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_AudioWithMusicBrainzTrackId_ReturnsCorrectUrl() + { + var audio = new Audio(); + audio.SetProviderId(MetadataProvider.MusicBrainzTrack, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(audio); + + Assert.Contains(PluginConfiguration.DefaultServer + "/track/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls); + } + + [Fact] + public void GetExternalUrls_AudioWithNoMusicBrainzTrackId_ReturnsNoUrl() + { + var audio = new Audio(); + + var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(audio); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_NonAudioWithMusicBrainzTrackId_ReturnsNoUrl() + { + var album = new MusicAlbum(); + album.SetProviderId(MetadataProvider.MusicBrainzTrack, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(album); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs new file mode 100644 index 0000000000..814375a49c --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs @@ -0,0 +1,193 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + // put tests that mock the static LibraryManager in the same collection to avoid test interference + [Collection("LibraryManagerTests")] + public sealed class TmdbExternalUrlProviderTests : IDisposable + { + private readonly TmdbExternalUrlProvider _provider = new(); + private readonly Mock<ILibraryManager> _libraryManagerMock = new(); + private readonly ILibraryManager? _previousLibraryManager; + + public TmdbExternalUrlProviderTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + BaseItem.LibraryManager = _libraryManagerMock.Object; + } + + public void Dispose() + { + BaseItem.LibraryManager = _previousLibraryManager; + } + + [Fact] + public void GetExternalUrls_SeriesWithTmdbId_ReturnsCorrectUrl() + { + var series = new Series(); + series.SetProviderId(MetadataProvider.Tmdb, "1399"); + + var urls = _provider.GetExternalUrls(series); + + Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399", urls); + } + + [Fact] + public void GetExternalUrls_SeriesWithNoTmdbId_ReturnsNoUrl() + { + var series = new Series(); + + var urls = _provider.GetExternalUrls(series); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_SeasonWithSeriesTmdbId_ReturnsCorrectUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + series.SetProviderId(MetadataProvider.Tmdb, "1399"); + + var season = new Season { IndexNumber = 3, SeriesId = series.Id }; + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + + var urls = _provider.GetExternalUrls(season); + + Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399/season/3", urls); + } + + [Fact] + public void GetExternalUrls_SeasonWithNoSeriesTmdbId_ReturnsNoUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + var season = new Season { IndexNumber = 1, SeriesId = series.Id }; + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + + var urls = _provider.GetExternalUrls(season); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_SeasonWithNoIndexNumber_ReturnsNoUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + series.SetProviderId(MetadataProvider.Tmdb, "1399"); + var season = new Season { IndexNumber = null, SeriesId = series.Id }; + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + + var urls = _provider.GetExternalUrls(season); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_EpisodeWithSeriesTmdbId_ReturnsCorrectUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + series.SetProviderId(MetadataProvider.Tmdb, "1399"); + + var season = new Season { Id = Guid.NewGuid(), IndexNumber = 2, SeriesId = series.Id }; + + var episode = new Episode + { + IndexNumber = 5, + SeasonId = season.Id, + SeriesId = series.Id + }; + + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + _libraryManagerMock.Setup(m => m.GetItemById(season.Id)).Returns(season); + + var urls = _provider.GetExternalUrls(episode); + + Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399/season/2/episode/5", urls); + } + + [Fact] + public void GetExternalUrls_EpisodeWithNoSeriesTmdbId_ReturnsNoUrl() + { + var series = new Series { Id = Guid.NewGuid() }; + var season = new Season { Id = Guid.NewGuid(), IndexNumber = 1, SeriesId = series.Id }; + var episode = new Episode { IndexNumber = 1, SeasonId = season.Id, SeriesId = series.Id }; + + _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series); + _libraryManagerMock.Setup(m => m.GetItemById(season.Id)).Returns(season); + + var urls = _provider.GetExternalUrls(episode); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_MovieWithTmdbId_ReturnsCorrectUrl() + { + var movie = new Movie(); + movie.SetProviderId(MetadataProvider.Tmdb, "550"); + + var urls = _provider.GetExternalUrls(movie); + + Assert.Contains(TmdbUtils.BaseTmdbUrl + "movie/550", urls); + } + + [Fact] + public void GetExternalUrls_MovieWithNoTmdbId_ReturnsNoUrl() + { + var movie = new Movie(); + + var urls = _provider.GetExternalUrls(movie); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_PersonWithTmdbId_ReturnsCorrectUrl() + { + var person = new Person(); + person.SetProviderId(MetadataProvider.Tmdb, "6384"); + + var urls = _provider.GetExternalUrls(person); + + Assert.Contains(TmdbUtils.BaseTmdbUrl + "person/6384", urls); + } + + [Fact] + public void GetExternalUrls_PersonWithNoTmdbId_ReturnsNoUrl() + { + var person = new Person(); + + var urls = _provider.GetExternalUrls(person); + + Assert.Empty(urls); + } + + [Fact] + public void GetExternalUrls_BoxSetWithTmdbId_ReturnsCorrectUrl() + { + var boxSet = new BoxSet(); + boxSet.SetProviderId(MetadataProvider.Tmdb, "10"); + + var urls = _provider.GetExternalUrls(boxSet); + + Assert.Contains(TmdbUtils.BaseTmdbUrl + "collection/10", urls); + } + + [Fact] + public void GetExternalUrls_BoxSetWithNoTmdbId_ReturnsNoUrl() + { + var boxSet = new BoxSet(); + + var urls = _provider.GetExternalUrls(boxSet); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs new file mode 100644 index 0000000000..dbe46d8fb1 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs @@ -0,0 +1,33 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.TV; +using Xunit; + +namespace Jellyfin.Providers.Tests.ExternalId +{ + public sealed class Zap2ItExternalUrlProviderTests + { + private readonly Zap2ItExternalUrlProvider _provider = new(); + + [Fact] + public void GetExternalUrls_ItemWithZap2ItId_ReturnsCorrectUrl() + { + var series = new Series(); + series.SetProviderId(MetadataProvider.Zap2It, "EP012345678901"); + + var urls = _provider.GetExternalUrls(series); + + Assert.Contains("http://tvlistings.zap2it.com/overview.html?programSeriesId=EP012345678901", urls); + } + + [Fact] + public void GetExternalUrls_ItemWithNoZap2ItId_ReturnsNoUrl() + { + var series = new Series(); + + var urls = _provider.GetExternalUrls(series); + + Assert.Empty(urls); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 87e7a4b564..5749944fcd 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -576,7 +576,8 @@ namespace Jellyfin.Providers.Tests.Manager baseItemManager!, Mock.Of<ILyricManager>(), Mock.Of<IMemoryCache>(), - Mock.Of<IMediaSegmentManager>()); + Mock.Of<IMediaSegmentManager>(), + Mock.Of<ISimilarItemsManager>()); return providerManager; } 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.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 222e624aa2..876f18741f 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -123,13 +123,13 @@ public class MediaInfoResolverTests var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); // any path other than test target exists and provides an empty listing - directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>())) .Returns(Array.Empty<string>()); _subtitleResolver.GetExternalFiles(video.Object, directoryService.Object, false); directoryService.Verify( - ds => ds.GetFilePaths(It.IsRegex(pathNotFoundRegex), It.IsAny<bool>(), It.IsAny<bool>()), + ds => ds.GetFilePaths(It.IsRegex(pathNotFoundRegex), It.IsAny<bool>()), Times.Never); } @@ -196,7 +196,7 @@ public class MediaInfoResolverTests }; var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); - directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>())) .Returns(Array.Empty<string>()); var mediaEncoder = Mock.Of<IMediaEncoder>(MockBehavior.Strict); @@ -341,9 +341,9 @@ public class MediaInfoResolverTests } var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); - directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) .Returns(files); - directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) .Returns(Array.Empty<string>()); List<MediaStream> GenerateMediaStreams() @@ -413,16 +413,16 @@ public class MediaInfoResolverTests var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict); if (useMetadataDirectory) { - directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) .Returns(Array.Empty<string>()); - directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) .Returns(new[] { MetadataDirectoryPath + "/" + file }); } else { - directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>())) .Returns(new[] { VideoDirectoryPath + "/" + file }); - directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>())) + directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>())) .Returns(Array.Empty<string>()); } 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..c8aa14af58 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -0,0 +1,233 @@ +#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 && (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 && (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/EpisodeResolverTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs index cc2e47c33a..16b601dc3c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs @@ -61,6 +61,94 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.NotNull(episodeResolver.Resolve(itemResolveArgs)); } + [Theory] + [InlineData("/media/Show/Season 01/Show S01E01 [tvdbid=12345].mkv", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show/Season 01/Show S01E01 [tvdbid-12345].mkv", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show/Season 01/Show S01E01 (tvdbid=12345).mkv", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show/Season 02/Show S02E03 [tvmazeid=67890].mkv", MetadataProvider.TvMaze, "67890")] + [InlineData("/media/Show/Season 02/Show S02E03 [tvmazeid-67890].mkv", MetadataProvider.TvMaze, "67890")] + [InlineData("/media/Show/Season 03/Show S03E04 [tmdbid=99999].mkv", MetadataProvider.Tmdb, "99999")] + [InlineData("/media/Show/Season 03/Show S03E04 [tmdbid-99999].mkv", MetadataProvider.Tmdb, "99999")] + [InlineData("/media/Show/Season 04/Show S04E05 [imdbid=tt1234567].mkv", MetadataProvider.Imdb, "tt1234567")] + [InlineData("/media/Show/Season 04/Show S04E05 [imdbid-tt1234567].mkv", MetadataProvider.Imdb, "tt1234567")] + public void Resolve_EpisodeFileWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId) + { + var series = new Series { Name = "Show" }; + var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); + var itemResolveArgs = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + CollectionType = CollectionType.tvshows, + FileInfo = new FileSystemMetadata + { + FullName = path, + IsDirectory = false + } + }; + + var episode = episodeResolver.Resolve(itemResolveArgs); + + Assert.NotNull(episode); + Assert.True(episode.TryGetProviderId(provider, out var actualId)); + Assert.Equal(expectedId, actualId); + } + + [Fact] + public void Resolve_EpisodeFileWithProviderIdsOnAllLevels_OnlyUsesEpisodeLevelId() + { + // Series folder has tvdbid=11111, season folder has tvdbid=22222, episode file has tvdbid=33333. + // The episode should only pick up its own ID, not the series- or season-level ones. + var series = new Series { Name = "Show" }; + var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); + var itemResolveArgs = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + CollectionType = CollectionType.tvshows, + FileInfo = new FileSystemMetadata + { + FullName = "/media/Show [tvdbid=11111]/Season 01 [tvdbid=22222]/Show S01E01 [tvdbid=33333].mkv", + IsDirectory = false + } + }; + + var episode = episodeResolver.Resolve(itemResolveArgs); + + Assert.NotNull(episode); + Assert.True(episode.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)); + Assert.Equal("33333", tvdbId); + } + + [Fact] + public void Resolve_EpisodeFileWithMultipleProviderIds_SetsAll() + { + var series = new Series { Name = "Show" }; + var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); + var itemResolveArgs = new ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + CollectionType = CollectionType.tvshows, + FileInfo = new FileSystemMetadata + { + FullName = "/media/Show/Season 01/Show S01E01 [tvdbid=12345][tmdbid=99999].mkv", + IsDirectory = false + } + }; + + var episode = episodeResolver.Resolve(itemResolveArgs); + + Assert.NotNull(episode); + Assert.True(episode.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)); + Assert.Equal("12345", tvdbId); + Assert.True(episode.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)); + Assert.Equal("99999", tmdbId); + } + private sealed class EpisodeResolverMock : EpisodeResolver { public EpisodeResolverMock(ILogger<EpisodeResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) : base(logger, namingOptions, directoryService) diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs index 8ed3d8b944..c80f899498 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs @@ -1,9 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; using AutoFixture; using AutoFixture.AutoMoq; +using Castle.Components.DictionaryAdapter; using Emby.Server.Implementations.IO; 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; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using Moq; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Library @@ -11,12 +24,28 @@ namespace Jellyfin.Server.Implementations.Tests.Library public class MediaSourceManagerTests { private readonly MediaSourceManager _mediaSourceManager; + private readonly Mock<IUserDataManager> _mockUserDataManager; + private readonly Mock<ILocalizationManager> _mockLocalizationManager; + private Video _item; + private User _user; public MediaSourceManagerTests() { IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); fixture.Inject<IFileSystem>(fixture.Create<ManagedFileSystem>()); + + _mockUserDataManager = fixture.Freeze<Mock<IUserDataManager>>(); + _mockUserDataManager.Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())).Returns(new UserItemData() { Key = "key" }); + + _mockLocalizationManager = fixture.Create<Mock<ILocalizationManager>>(); + _mockLocalizationManager.Setup(m => m.FindLanguageInfo(It.IsAny<string>())).Returns((string s) => string.IsNullOrEmpty(s) ? null : new CultureDto(s, s, s, new EditableList<string> { s })); + fixture.Inject(_mockLocalizationManager.Object); + _mediaSourceManager = fixture.Create<MediaSourceManager>(); + + _item = new Video { Id = Guid.NewGuid(), OwnerId = Guid.Empty, ParentId = Guid.Empty }; + + _user = fixture.Create<User>(); } [Theory] @@ -28,5 +57,196 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("rtsp://media.example.com:554/twister/audiotrack", MediaProtocol.Rtsp)] public void GetPathProtocol_ValidArg_Correct(string path, MediaProtocol expected) => Assert.Equal(expected, _mediaSourceManager.GetPathProtocol(path)); + + [Theory] + [InlineData(5, "eng", "eng", false, true)] + [InlineData(5, "eng", "eng", true, true)] + [InlineData(2, "ger", "eng", false, true)] + [InlineData(2, "ger", "eng", true, true)] + [InlineData(1, "fre", "eng", false, true)] + [InlineData(2, "fre", "eng", true, true)] + [InlineData(5, "OriginalLanguage", "eng", false, false)] + [InlineData(4, "OriginalLanguage", "eng", false, true)] + [InlineData(5, "OriginalLanguage", "eng", true, false)] + [InlineData(5, "OriginalLanguage", "eng", true, true)] + [InlineData(2, "OriginalLanguage", "jpn", true, true)] + [InlineData(2, "OriginalLanguage", "jpn", false, true)] + [InlineData(2, "OriginalLanguage", "jpn,eng", false, true)] + [InlineData(4, "OriginalLanguage", null, false, true)] + [InlineData(2, "OriginalLanguage", null, true, true)] + [InlineData(4, "OriginalLanguage", "", false, true)] + [InlineData(2, "OriginalLanguage", "", false, false)] + [InlineData(2, "OriginalLanguage", "ger", false, true)] + [InlineData(2, "OriginalLanguage", "ger", false, false)] + [InlineData(1, "OriginalLanguage", "fre", false, false)] + [InlineData(2, "OriginalLanguage", "fre", true, true)] + [InlineData(2, "OriginalLanguage", "fre", true, false)] + public void SetDefaultAudioStreamIndex_Index_Correct( + int expectedIndex, + string prefferedLanguage, + string? originalLanguage, + bool playDefault, + bool originalExist) + { + var streams = new MediaStream[] + { + new() + { + Index = 0, + Type = MediaStreamType.Video, + IsDefault = true + }, + new() + { + Index = 1, + Type = MediaStreamType.Audio, + Language = "fre", + IsDefault = false, + IsOriginal = false + }, + new() + { + Index = 2, + Type = MediaStreamType.Audio, + Language = "jpn", + IsDefault = true, + IsOriginal = false + }, + new() + { + Index = 3, + Type = MediaStreamType.Audio, + Language = "eng", + IsDefault = false, + IsOriginal = false + }, + new() + { + Index = 4, + Type = MediaStreamType.Audio, + Language = "eng", + IsDefault = false, + IsOriginal = originalExist, + }, + new() + { + Index = 5, + Type = MediaStreamType.Audio, + Language = "eng", + IsDefault = true, + IsOriginal = false, + } + }; + var mediaInfo = new MediaSourceInfo + { + MediaStreams = streams + }; + _user.AudioLanguagePreference = prefferedLanguage; + _user.PlayDefaultAudioTrack = playDefault; + _item.OriginalLanguage = originalLanguage; + + _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/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs index aed584355c..e1346a8436 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs @@ -1,7 +1,13 @@ +using System.Collections.Generic; using Emby.Naming.Common; +using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers.Movies; +using Jellyfin.Data.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -14,11 +20,12 @@ namespace Jellyfin.Server.Implementations.Tests.Library; public class MovieResolverTests { private static readonly NamingOptions _namingOptions = new(); + private static readonly VideoListResolver _videoListResolver = new(_namingOptions); [Fact] public void Resolve_GivenLocalAlternateVersion_ResolvesToVideo() { - var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); var itemResolveArgs = new ItemResolveArgs( Mock.Of<IServerApplicationPaths>(), null) @@ -32,4 +39,54 @@ public class MovieResolverTests Assert.NotNull(movieResolver.Resolve(itemResolveArgs)); } + + [Fact] + public void ResolveMultiple_GivenTvShowsCollection_CreatesEpisodeItems() + { + // For a tvshows collection, the multi-version grouping must still produce + // Episode BaseItems (not generic Video) so downstream metadata fetching + // and series-aware logic apply. + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + + var parent = new Folder { Path = "/TV/Show/Season 1" }; + var files = new List<FileSystemMetadata> + { + new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv", Name = "Show - S01E01 - 1080p.mkv", IsDirectory = false }, + new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", Name = "Show - S01E01 - 720p.mkv", IsDirectory = false }, + new() { FullName = "/TV/Show/Season 1/Show - S01E02.mkv", Name = "Show - S01E02.mkv", IsDirectory = false } + }; + + var result = movieResolver.ResolveMultiple(parent, files, CollectionType.tvshows, Mock.Of<IDirectoryService>()); + + Assert.NotNull(result); + Assert.Equal(2, result.Items.Count); + Assert.All(result.Items, item => Assert.IsType<Episode>(item)); + + // The S01E01 item should have one alternate version + var s01e01 = result.Items.Find(i => i.Path.Contains("S01E01", System.StringComparison.Ordinal)); + Assert.NotNull(s01e01); + Assert.Single(((Video)s01e01).LocalAlternateVersions); + } + + [Fact] + public void ResolveMultiple_GivenMoviesCollection_CreatesMovieItems() + { + // For a movies collection, the multi-version grouping must produce Movie + // BaseItems (not generic Video) so downstream movie-specific logic applies. + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + + var parent = new Folder { Path = "/movies/Inception (2010)" }; + var files = new List<FileSystemMetadata> + { + new() { FullName = "/movies/Inception (2010)/Inception (2010) - 1080p.mkv", Name = "Inception (2010) - 1080p.mkv", IsDirectory = false }, + new() { FullName = "/movies/Inception (2010)/Inception (2010) - 720p.mkv", Name = "Inception (2010) - 720p.mkv", IsDirectory = false } + }; + + var result = movieResolver.ResolveMultiple(parent, files, CollectionType.movies, Mock.Of<IDirectoryService>()); + + Assert.NotNull(result); + Assert.Single(result.Items); + Assert.All(result.Items, item => Assert.IsType<Movie>(item)); + Assert.Single(((Video)result.Items[0]).LocalAlternateVersions); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs new file mode 100644 index 0000000000..133a3f7d47 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs @@ -0,0 +1,145 @@ +using Emby.Naming.Common; +using Emby.Server.Implementations.Library.Resolvers.TV; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library +{ + public class SeasonResolverTests + { + private static readonly NamingOptions _namingOptions = new(); + private readonly SeasonResolver _resolver; + + public SeasonResolverTests() + { + var localizationMock = new Mock<ILocalizationManager>(); + localizationMock + .Setup(l => l.GetLocalizedString(It.IsAny<string>())) + .Returns("Season {0}"); + + _resolver = new SeasonResolver( + _namingOptions, + localizationMock.Object, + Mock.Of<ILogger<SeasonResolver>>()); + } + + [Theory] + [InlineData("/media/Show/Season 01 [tvdbid=12345]", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show/Season 01 [tvdbid-12345]", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show/Season 01 (tvdbid=12345)", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show/Season 02 [tvmazeid=67890]", MetadataProvider.TvMaze, "67890")] + [InlineData("/media/Show/Season 02 [tvmazeid-67890]", MetadataProvider.TvMaze, "67890")] + [InlineData("/media/Show/Season 03 [tmdbid=99999]", MetadataProvider.Tmdb, "99999")] + [InlineData("/media/Show/Season 03 [tmdbid-99999]", MetadataProvider.Tmdb, "99999")] + public void Resolve_SeasonFolderWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId) + { + var series = new Series { Path = "/media/Show" }; + + var args = new MediaBrowser.Controller.Library.ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + LibraryOptions = new LibraryOptions(), + FileInfo = new FileSystemMetadata + { + FullName = path, + IsDirectory = true + } + }; + + var season = _resolver.Resolve(args); + + Assert.NotNull(season); + Assert.True(season.TryGetProviderId(provider, out var actualId)); + Assert.Equal(expectedId, actualId); + } + + [Fact] + public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll() + { + var series = new Series { Path = "/media/Show" }; + + var args = new MediaBrowser.Controller.Library.ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + LibraryOptions = new LibraryOptions(), + FileInfo = new FileSystemMetadata + { + FullName = "/media/Show/Season 01 [tvdbid=12345][tmdbid=99999]", + IsDirectory = true + } + }; + + var season = _resolver.Resolve(args); + + Assert.NotNull(season); + Assert.True(season.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)); + Assert.Equal("12345", tvdbId); + Assert.True(season.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)); + Assert.Equal("99999", tmdbId); + } + + [Fact] + public void Resolve_SeasonFolderWithSeriesProviderIdInParentPath_DoesNotInheritSeriesId() + { + // Series folder has tvdbid=11111, season folder has tvdbid=22222. + // The season should only pick up its own ID, not the series-level one. + var series = new Series { Path = "/media/Show [tvdbid=11111]" }; + + var args = new MediaBrowser.Controller.Library.ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + LibraryOptions = new LibraryOptions(), + FileInfo = new FileSystemMetadata + { + FullName = "/media/Show [tvdbid=11111]/Season 01 [tvdbid=22222]", + IsDirectory = true + } + }; + + var season = _resolver.Resolve(args); + + Assert.NotNull(season); + Assert.True(season.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)); + Assert.Equal("22222", tvdbId); + } + + [Fact] + public void Resolve_SeasonFolderWithNoProviderId_HasNoProviderIds() + { + var series = new Series { Path = "/media/Show" }; + + var args = new MediaBrowser.Controller.Library.ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + null) + { + Parent = series, + LibraryOptions = new LibraryOptions(), + FileInfo = new FileSystemMetadata + { + FullName = "/media/Show/Season 01", + IsDirectory = true + } + }; + + var season = _resolver.Resolve(args); + + Assert.NotNull(season); + Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _)); + Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _)); + Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _)); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs new file mode 100644 index 0000000000..8dbd5f5b41 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs @@ -0,0 +1,124 @@ +using Emby.Naming.Common; +using Emby.Server.Implementations.Library.Resolvers.TV; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library +{ + public class SeriesResolverTests + { + private static readonly NamingOptions _namingOptions = new(); + private readonly SeriesResolver _resolver; + private readonly Mock<ILibraryManager> _libraryManagerMock; + + public SeriesResolverTests() + { + _libraryManagerMock = new Mock<ILibraryManager>(); + // Return null so that configuredContentType != CollectionType.tvshows, allowing series resolution. + _libraryManagerMock + .Setup(m => m.GetConfiguredContentType(It.IsAny<string>())) + .Returns((CollectionType?)null); + + _resolver = new SeriesResolver(Mock.Of<ILogger<SeriesResolver>>(), _namingOptions); + } + + private MediaBrowser.Controller.Library.ItemResolveArgs MakeTvArgs(string path) => + new(Mock.Of<IServerApplicationPaths>(), _libraryManagerMock.Object) + { + CollectionType = CollectionType.tvshows, + FileSystemChildren = [], + FileInfo = new FileSystemMetadata + { + FullName = path, + IsDirectory = true + } + }; + + [Theory] + [InlineData("/media/Show [tvdbid=12345]", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show [tvdbid-12345]", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show (tvdbid=12345)", MetadataProvider.Tvdb, "12345")] + [InlineData("/media/Show [tvmazeid=67890]", MetadataProvider.TvMaze, "67890")] + [InlineData("/media/Show [tvmazeid-67890]", MetadataProvider.TvMaze, "67890")] + [InlineData("/media/Show [tmdbid=99999]", MetadataProvider.Tmdb, "99999")] + [InlineData("/media/Show [tmdbid-99999]", MetadataProvider.Tmdb, "99999")] + [InlineData("/media/Show [imdbid=tt1234567]", MetadataProvider.Imdb, "tt1234567")] + [InlineData("/media/Show [imdbid-tt1234567]", MetadataProvider.Imdb, "tt1234567")] + public void ResolvePath_SeriesFolderWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId) + { + var series = _resolver.ResolvePath(MakeTvArgs(path)) as Series; + + Assert.NotNull(series); + Assert.True(series.TryGetProviderId(provider, out var actualId)); + Assert.Equal(expectedId, actualId); + } + + [Theory] + [InlineData("/media/Show [anidbid=11111]", "AniDB", "11111")] + [InlineData("/media/Show [anilistid=22222]", "AniList", "22222")] + [InlineData("/media/Show [anisearchid=33333]", "AniSearch", "33333")] + public void ResolvePath_SeriesFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId) + { + var series = _resolver.ResolvePath(MakeTvArgs(path)) as Series; + + Assert.NotNull(series); + Assert.True(series.TryGetProviderId(providerKey, out var actualId)); + Assert.Equal(expectedId, actualId); + } + + [Fact] + public void ResolvePath_SeriesFolderWithMultipleProviderIds_SetsAll() + { + var series = _resolver.ResolvePath(MakeTvArgs("/media/Show [tvdbid=12345][tmdbid=99999]")) as Series; + + Assert.NotNull(series); + Assert.True(series.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)); + Assert.Equal("12345", tvdbId); + Assert.True(series.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)); + Assert.Equal("99999", tmdbId); + } + + [Fact] + public void ResolvePath_SeriesFolderWithNoProviderId_HasNoProviderIds() + { + var series = _resolver.ResolvePath(MakeTvArgs("/media/Show")) as Series; + + Assert.NotNull(series); + Assert.False(series.TryGetProviderId(MetadataProvider.Tvdb, out _)); + Assert.False(series.TryGetProviderId(MetadataProvider.TvMaze, out _)); + Assert.False(series.TryGetProviderId(MetadataProvider.Tmdb, out _)); + Assert.False(series.TryGetProviderId(MetadataProvider.Imdb, out _)); + Assert.False(series.TryGetProviderId("AniDB", out _)); + Assert.False(series.TryGetProviderId("AniList", out _)); + Assert.False(series.TryGetProviderId("AniSearch", out _)); + } + + [Fact] + public void ResolvePath_SeriesFolderNotInTvShowsCollection_DoesNotResolve() + { + // Without CollectionType.tvshows, a plain folder with no tvshow.nfo and + // no season/episode children should not resolve as a Series. + var args = new MediaBrowser.Controller.Library.ItemResolveArgs( + Mock.Of<IServerApplicationPaths>(), + _libraryManagerMock.Object) + { + CollectionType = null, + FileSystemChildren = [], + FileInfo = new FileSystemMetadata + { + FullName = "/media/Show [tvdbid=12345]", + IsDirectory = true + } + }; + + Assert.Null(_resolver.ResolvePath(args)); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 5bcfc580ff..bdb726f06d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using BitFaster.Caching; @@ -242,6 +243,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization } [Theory] + [InlineData("US:INVALID", "US")] // Colon separator, known country code, unknown rating + [InlineData("us:INVALID", "US")] // Colon separator, lowercase country code + [InlineData("DE-INVALID", "US")] // Hyphen separator, known language prefix, unknown rating + [InlineData("ca:INVALID", "US")] // Colon separator, known country code (Canada) + public async Task GetRatingScore_UnknownRatingWithKnownCountry_ReturnsNull(string rating, string countryCode) + { + var localizationManager = Setup(new ServerConfiguration + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + Assert.Null(localizationManager.GetRatingScore(rating)); + } + + [Theory] + [InlineData("us:R", "DE", 17, 0)] // Colon separator, explicit US country, valid US rating + [InlineData("US:PG-13", "DE", 13, 0)] // Colon separator, explicit US country, valid US rating + [InlineData("ca:R", "US", 18, 1)] // Colon separator, Canada country code, valid CA rating + public async Task GetRatingScore_ValidRatingWithCountrySeparator_ReturnsScore(string rating, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(rating); + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Theory] [InlineData("Default", "Default")] [InlineData("HeaderLiveTV", "Live TV")] public void GetLocalizedString_Valid_Success(string key, string expected) @@ -271,6 +306,112 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(key, translated); } + [Fact] + public void GetLocalizedString_WithCulture_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + var translated = localizationManager.GetLocalizedString("Artists", "de"); + Assert.Equal("Interpreten", translated); + } + + [Fact] + public void GetLocalizedString_WithCulture_FallsBackToEnUs() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // A culture with no translation file should fall back to en-US + var translated = localizationManager.GetLocalizedString("Artists", "zz"); + Assert.Equal("Artists", translated); + } + + [Fact] + public void GetLocalizedString_WithBcp47Normalization_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // es-419 is stored as es_419 in Jellyfin + var translated = localizationManager.GetLocalizedString("Default", "es-419"); + Assert.NotEqual("Default", translated); + } + + [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 + { + UICulture = "de" + }); + + // Even if CurrentUICulture is fr, GetServerLocalizedString should use the server's "de" + var previousCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr"); + var translated = localizationManager.GetServerLocalizedString("Artists"); + Assert.Equal("Interpreten", translated); + } + finally + { + CultureInfo.CurrentUICulture = previousCulture; + } + } + + [Fact] + public void GetLocalizedString_UsesCurrentUICulture() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + var previousCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); + var translated = localizationManager.GetLocalizedString("Artists"); + Assert.Equal("Interpreten", translated); + } + finally + { + CultureInfo.CurrentUICulture = previousCulture; + } + } + + [Fact] + public void GetSupportedUICultures_IncludesCommonCultures() + { + var supported = LocalizationManager.GetSupportedUICultures(); + Assert.Contains(supported, c => c.Name.Equals("de", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(supported, c => c.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(supported, c => c.Name.Equals("fr", StringComparison.OrdinalIgnoreCase)); + // Underscore variants get normalized to BCP-47 hyphen form for CultureInfo compatibility. + Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase)); + } + private LocalizationManager Setup(ServerConfiguration config) { var mockConfiguration = new Mock<IServerConfigurationManager>(); 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.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/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 0952fb8b63..54f443de2d 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -111,7 +111,7 @@ namespace Jellyfin.Server.Integration.Tests var appHost = (TestAppHost)host.Services.GetRequiredService<IApplicationHost>(); appHost.ServiceProvider = host.Services; var applicationPaths = appHost.ServiceProvider.GetRequiredService<IApplicationPaths>(); - Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>()).GetAwaiter().GetResult(); + Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>(), new()).GetAwaiter().GetResult(); Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult(); appHost.InitializeServices(Mock.Of<IConfiguration>()).GetAwaiter().GetResult(); Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult(); 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; + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index 1e8652f4b9..4142831c31 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -294,5 +294,48 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers // Verify that the lowercase "tmdbcol" is NOT in the provider IDs Assert.False(item.ProviderIds.ContainsKey("tmdbcol")); } + + [Fact] + public void Parse_CommunityRating_ValidRating_Success() + { + var result = new MetadataResult<Video>() + { + Item = new Movie() + }; + + _parser.Fetch(result, "Test Data/CommunityRating.nfo", CancellationToken.None); + var item = (Movie)result.Item; + + Assert.Equal(7.5f, item.CommunityRating); + } + + [Fact] + public void Parse_CommunityRating_OutOfRange_Ignored() + { + var result = new MetadataResult<Video>() + { + Item = new Movie() + }; + + _parser.Fetch(result, "Test Data/CommunityRating_OutOfRange.nfo", CancellationToken.None); + var item = (Movie)result.Item; + + // Rating should not be set if outside 0-10 range + Assert.Null(item.CommunityRating); + } + + [Fact] + public void Parse_CommunityRating_Comma() + { + var result = new MetadataResult<Video>() + { + Item = new Movie() + }; + + _parser.Fetch(result, "Test Data/CommunityRating_Comma.nfo", CancellationToken.None); + var item = (Movie)result.Item; + + Assert.Equal(7.5f, item.CommunityRating); + } } } diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo new file mode 100644 index 0000000000..387de10c0e --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<movie> + <title>Test Movie</title> + <communityrating>7.5</communityrating> +</movie>
\ No newline at end of file diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo new file mode 100644 index 0000000000..4ec215e2e1 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<movie> + <title>Test Movie</title> + <communityrating>7,5</communityrating> +</movie>
\ No newline at end of file diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo new file mode 100644 index 0000000000..126854edd3 --- /dev/null +++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<movie> + <title>Test Movie</title> + <communityrating>15.5</communityrating> +</movie>
\ No newline at end of file |
