diff options
| author | Ronan Charles-Lorel <roro.roronoa@gmail.com> | 2023-06-29 15:08:52 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-06-29 15:08:52 +0200 |
| commit | e108183b138552013bbfd13c36937481228eb9e6 (patch) | |
| tree | 8e374adf35d64b157ac88e5b84a25d186bd4ccf1 | |
| parent | 31ac861b8560547c7e0c46513077abf76e6bc618 (diff) | |
| parent | b5bbb98175e0542d43c01f80c15e8dce04e58b53 (diff) | |
Merge branch 'jellyfin:master' into master
683 files changed, 33887 insertions, 28026 deletions
diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 1618237f1a..c28b1bf7f0 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -47,7 +47,7 @@ jobs: displayName: Set release version (stable) condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') - - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) deployment' + - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) --label "org.opencontainers.image.url=$(Build.Repository.Uri)" --label "org.opencontainers.image.revision=$(Build.SourceVersion)" deployment' displayName: 'Build Dockerfile' - script: 'docker image ls -a && docker run -v $(pwd)/deployment/dist:/dist -v $(pwd):/jellyfin -e IS_UNSTABLE="yes" -e BUILD_ID=$(Build.BuildNumber) jellyfin-server-$(BuildConfiguration)' diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index fd377df9db..5878028330 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -30,9 +30,9 @@ body: label: Jellyfin Version description: What version of Jellyfin are you running? options: - - 10.8.0 + - 10.8.z + - 10.8.9 - 10.7.7 - - 10.7.z - 10.6.4 - Other validations: @@ -47,13 +47,15 @@ body: label: Environment description: | Examples: - - **OS**: [e.g. Debian, Windows] + - **OS**: [e.g. Debian 11, Windows 10] + - **Linux Kernel**: [e.g. none, 5.15, 6.1, etc.] - **Virtualization**: [e.g. Docker, KVM, LXC] - **Clients**: [Browser, Android, Fire Stick, etc.] - **Browser**: [e.g. Firefox 91, Chrome 93, Safari 13] - - **FFmpeg Version**: [e.g. 4.3.2-Jellyfin] + - **FFmpeg Version**: [e.g. 5.1.2-Jellyfin] - **Playback**: [Direct Play, Remux, Direct Stream, Transcode] - **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.] + - **GPU Model**: [e.g. none, UHD630, GTX1050, etc.] - **Installed Plugins**: [e.g. none, Fanart, Anime, etc.] - **Reverse Proxy**: [e.g. none, nginx, apache, etc.] - **Base URL**: [e.g. none, yes: /example] @@ -61,12 +63,14 @@ body: - **Storage**: [e.g. local, NFS, cloud] value: | - OS: + - Linux Kernel: - Virtualization: - Clients: - Browser: - FFmpeg Version: - Playback Method: - Hardware Acceleration: + - GPU Model: - Plugins: - Reverse Proxy: - Base URL: @@ -84,8 +88,8 @@ body: id: ffmpeg-logs attributes: label: FFmpeg logs - description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. - placeholder: It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg. + description: Please copy and paste recent FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log. + placeholder: This field is mandatory for debugging hardware transcoding issues. It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg. render: shell - type: textarea id: browserlogs diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 4b5571c774..47abce02a3 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -19,6 +19,7 @@ jobs: if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' + commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' repoToken: ${{ secrets.JF_BOT_TOKEN }} project: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5779ac3cf9..f83b38949c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@3ebbd71c74ef574dbc558c82f70e52732c8b44fe # v2 + uses: github/codeql-action/init@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@3ebbd71c74ef574dbc558c82f70e52732c8b44fe # v2 + uses: github/codeql-action/autobuild@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3ebbd71c74ef574dbc558c82f70e52732c8b44fe # v2 + uses: github/codeql-action/analyze@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 5d945c001b..178959afc9 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -17,14 +17,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 with: token: ${{ secrets.JF_BOT_TOKEN }} comment-id: ${{ github.event.comment.id }} reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -51,14 +51,14 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 - name: Notify as running id: comment_running - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -93,7 +93,7 @@ jobs: exit ${retcode} - name: Notify with result success - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null && success() }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -108,7 +108,7 @@ jobs: reactions: hooray - name: Notify with result failure - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null && failure() }} with: token: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 4577ff5251..d3dfd0a6aa 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -14,18 +14,18 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3 + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: openapi-head retention-days: 14 @@ -39,25 +39,27 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} fetch-depth: 0 - name: Checkout common ancestor + env: + HEAD_REF: ${{ github.head_ref }} run: | git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }} git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/* - ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/${{ github.head_ref }}) + ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF) git checkout --progress --force $ANCESTOR_REF - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3 + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: openapi-base retention-days: 14 @@ -76,12 +78,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3 + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3 + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 with: name: openapi-base path: openapi-base @@ -103,14 +105,14 @@ jobs: body="${body//$'\r'/'%0D'}" echo ::set-output name=body::$body - name: Find difference comment - uses: peter-evans/find-comment@81e2da3af01c92f83cb927cf3ace0e085617c556 # v2 + uses: peter-evans/find-comment@a54c31d7fa095754bfef525c0c8e5e5674c4b4b1 # v2.4.0 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} direction: last body-includes: openapi-diff-workflow-comment - name: Reply or edit difference comment (changed) - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ steps.read-diff.outputs.body != '' }} with: issue-number: ${{ github.event.pull_request.number }} @@ -125,7 +127,7 @@ jobs: </details> - name: Edit difference comment (unchanged) - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml index 7f6fcffed5..c753c1600a 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -1,4 +1,4 @@ -name: Issue Stale Check +name: Stale Check on: schedule: @@ -7,12 +7,15 @@ on: permissions: issues: write + pull-requests: write + jobs: - stale: + issues: + name: Check issues runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@6f05e4244c9a0b2ed3401882b05d701dd0a7289b # v7 + - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} days-before-stale: 120 @@ -28,3 +31,21 @@ jobs: If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label. This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html). + + prs-conflicts: + name: Check PRs with merge conflicts + runs-on: ubuntu-latest + if: ${{ contains(github.repository, 'jellyfin/') }} + steps: + - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 + with: + repo-token: ${{ secrets.JF_BOT_TOKEN }} + operations-per-run: 75 + # The merge conflict action will remove the label when updated + remove-stale-when-updated: false + days-before-stale: -1 + days-before-close: 90 + days-before-issue-close: -1 + stale-pr-label: merge conflict + close-pr-message: |- + This PR has been closed due to having unresolved merge conflicts. diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b7a317000b..0000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -registry=https://registry.npmjs.org/ -@jellyfin:registry=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/ -always-auth=true
\ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ec3c6fd2af..dfb61df0a1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -58,6 +58,7 @@ - [HelloWorld017](https://github.com/HelloWorld017) - [ikomhoog](https://github.com/ikomhoog) - [jftuga](https://github.com/jftuga) + - [jmshrv](https://github.com/jmshrv) - [joern-h](https://github.com/joern-h) - [joshuaboniface](https://github.com/joshuaboniface) - [JustAMan](https://github.com/JustAMan) @@ -125,6 +126,7 @@ - [SuperSandro2000](https://github.com/SuperSandro2000) - [tbraeutigam](https://github.com/tbraeutigam) - [teacupx](https://github.com/teacupx) + - [TelepathicWalrus](https://github.com/TelepathicWalrus) - [Terror-Gene](https://github.com/Terror-Gene) - [ThatNerdyPikachu](https://github.com/ThatNerdyPikachu) - [ThibaultNocchi](https://github.com/ThibaultNocchi) @@ -162,6 +164,8 @@ - [vgambier](https://github.com/vgambier) - [MinecraftPlaye](https://github.com/MinecraftPlaye) - [RealGreenDragon](https://github.com/RealGreenDragon) + - [ipitio](https://github.com/ipitio) + - [TheTyrius](https://github.com/TheTyrius) # Emby Contributors @@ -231,3 +235,4 @@ - [Matthew Jones](https://github.com/matthew-jones-uk) - [Jakob Kukla](https://github.com/jakobkukla) - [Utku Özdemir](https://github.com/utkuozdemir) + - [JPUC1143](https://github.com/Jpuc1143/) diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000000..c3532467af --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,92 @@ +<Project> + <PropertyGroup> + <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> + </PropertyGroup> + + <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.--> + + <ItemGroup Label="Package Dependencies"> + <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.0" /> + <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.0" /> + <PackageVersion Include="AutoFixture" Version="4.18.0" /> + <PackageVersion Include="BDInfo" Version="0.7.6.2" /> + <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.2.0" /> + <PackageVersion Include="BlurHashSharp" Version="1.2.0" /> + <PackageVersion Include="CommandLineParser" Version="2.9.1" /> + <PackageVersion Include="coverlet.collector" Version="6.0.0" /> + <PackageVersion Include="Diacritics" Version="3.3.18" /> + <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> + <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> + <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.2" /> + <PackageVersion Include="FsCheck.Xunit" Version="2.16.5" /> + <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> + <PackageVersion Include="libse" Version="3.6.13" /> + <PackageVersion Include="LrcParser" Version="2023.524.0" /> + <PackageVersion Include="MetaBrainz.MusicBrainz" Version="5.0.0" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.8" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.8" /> + <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.8" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.8" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.8" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.4" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="7.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="7.0.1" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.6.3" /> + <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" /> + <PackageVersion Include="MimeTypes" Version="2.4.0" /> + <PackageVersion Include="Mono.Nat" Version="3.0.4" /> + <PackageVersion Include="Moq" Version="4.18.4" /> + <PackageVersion Include="NEbml" Version="0.11.0" /> + <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> + <PackageVersion Include="PlaylistsNET" Version="1.4.0" /> + <PackageVersion Include="prometheus-net.AspNetCore" Version="8.0.0" /> + <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" /> + <PackageVersion Include="prometheus-net" Version="8.0.0" /> + <PackageVersion Include="Serilog.AspNetCore" Version="7.0.0" /> + <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" /> + <PackageVersion Include="Serilog.Settings.Configuration" Version="7.0.0" /> + <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" /> + <PackageVersion Include="Serilog.Sinks.Console" Version="4.1.0" /> + <PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" /> + <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.0.1" /> + <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> + <PackageVersion Include="SharpFuzz" Version="2.1.0" /> + <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" /> + <PackageVersion Include="SkiaSharp.Svg" Version="1.60.0" /> + <PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.3" /> + <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="2.8.2.3" /> + <PackageVersion Include="SkiaSharp" Version="2.88.3" /> + <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> + <PackageVersion Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> + <PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.5" /> + <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.507" /> + <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.4.0" /> + <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" /> + <PackageVersion Include="System.Globalization" Version="4.3.0" /> + <PackageVersion Include="System.Linq.Async" Version="6.0.1" /> + <PackageVersion Include="System.Text.Encoding.CodePages" Version="7.0.0" /> + <PackageVersion Include="System.Text.Json" Version="7.0.3" /> + <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="7.0.0" /> + <PackageVersion Include="TagLibSharp" Version="2.3.0" /> + <PackageVersion Include="TMDbLib" Version="2.0.0" /> + <PackageVersion Include="UTF.Unknown" Version="2.5.1" /> + <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> + <PackageVersion Include="xunit.runner.visualstudio" Version="2.4.5" /> + <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" /> + <PackageVersion Include="xunit" Version="2.4.2" /> + </ItemGroup> +</Project> diff --git a/Dockerfile b/Dockerfile index 304f794631..e51d285e12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ + && npm run build:production \ && mv dist /dist FROM debian:stable-slim as app @@ -37,7 +38,7 @@ RUN apt-get update \ && apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y \ mesa-va-drivers \ - jellyfin-ffmpeg \ + jellyfin-ffmpeg5 \ openssl \ locales \ # Intel VAAPI Tone mapping dependencies: diff --git a/Dockerfile.arm b/Dockerfile.arm index bbb84a461c..46a3e9b998 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -11,6 +11,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ + && npm run build:production \ && mv dist /dist FROM multiarch/qemu-user-static:x86_64-arm as qemu diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 5572586ae9..4f9d5e1fdc 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -11,6 +11,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ + && npm run build:production \ && mv dist /dist FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs index c484dac542..db1190ae7c 100644 --- a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs +++ b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs @@ -27,7 +27,7 @@ namespace Emby.Dlna.ConnectionManager /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns> private static IEnumerable<StateVariable> GetStateVariables() { - var list = new List<StateVariable> + return new StateVariable[] { new StateVariable { @@ -114,8 +114,6 @@ namespace Emby.Dlna.ConnectionManager SendsEvents = false } }; - - return list; } } } diff --git a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs index 3edaabb70e..9af28aa7cb 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs @@ -27,7 +27,7 @@ namespace Emby.Dlna.ContentDirectory /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns> private static IEnumerable<StateVariable> GetStateVariables() { - var list = new List<StateVariable> + return new StateVariable[] { new StateVariable { @@ -154,8 +154,6 @@ namespace Emby.Dlna.ContentDirectory SendsEvents = false } }; - - return list; } } } diff --git a/Emby.Dlna/Didl/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs index bea7a5a0da..f668dc829a 100644 --- a/Emby.Dlna/Didl/DidlBuilder.cs +++ b/Emby.Dlna/Didl/DidlBuilder.cs @@ -10,6 +10,7 @@ using System.Text; using System.Xml; using Emby.Dlna.ContentDirectory; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; @@ -870,11 +871,11 @@ namespace Emby.Dlna.Didl var types = new[] { - PersonType.Director, - PersonType.Writer, - PersonType.Producer, - PersonType.Composer, - "creator" + PersonKind.Director, + PersonKind.Writer, + PersonKind.Producer, + PersonKind.Composer, + PersonKind.Creator }; // Seeing some LG models locking up due content with large lists of people @@ -888,10 +889,13 @@ namespace Emby.Dlna.Didl foreach (var actor in people) { - var type = types.FirstOrDefault(i => string.Equals(i, actor.Type, StringComparison.OrdinalIgnoreCase) || string.Equals(i, actor.Role, StringComparison.OrdinalIgnoreCase)) - ?? PersonType.Actor; + var type = types.FirstOrDefault(i => i == actor.Type || string.Equals(actor.Role, i.ToString(), StringComparison.OrdinalIgnoreCase)); + if (type == PersonKind.Unknown) + { + type = PersonKind.Actor; + } - AddValue(writer, "upnp", type.ToLowerInvariant(), actor.Name, NsUpnp); + AddValue(writer, "upnp", type.ToString().ToLowerInvariant(), actor.Name, NsUpnp); } } diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 60e6dd644d..aca2399644 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -28,13 +28,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> @@ -80,7 +80,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" /> + <PackageReference Include="Microsoft.Extensions.Http" /> </ItemGroup> </Project> diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index c0eacf5d83..ecbbdf9df9 100644 --- a/Emby.Dlna/Eventing/DlnaEventManager.cs +++ b/Emby.Dlna/Eventing/DlnaEventManager.cs @@ -164,7 +164,7 @@ namespace Emby.Dlna.Eventing try { - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + using var response = await _httpClientFactory.CreateClient(NamedClient.DirectIp) .SendAsync(options, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); } catch (OperationCanceledException) diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index aab475153b..39cfc2d1d4 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -7,7 +7,6 @@ using System.Globalization; using System.Linq; using System.Net.Http; using System.Net.Sockets; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Emby.Dlna.PlayTo; using Emby.Dlna.Ssdp; diff --git a/Emby.Dlna/PlayTo/DlnaHttpClient.cs b/Emby.Dlna/PlayTo/DlnaHttpClient.cs index 75ff542dd8..8b983e9e3d 100644 --- a/Emby.Dlna/PlayTo/DlnaHttpClient.cs +++ b/Emby.Dlna/PlayTo/DlnaHttpClient.cs @@ -2,9 +2,11 @@ using System; using System.Globalization; +using System.IO; using System.Net.Http; using System.Net.Mime; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Xml; @@ -15,7 +17,10 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.PlayTo { - public class DlnaHttpClient + /// <summary> + /// Http client for Dlna PlayTo function. + /// </summary> + public partial class DlnaHttpClient { private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; @@ -44,25 +49,44 @@ namespace Emby.Dlna.PlayTo private async Task<XDocument?> SendRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - using var response = await _httpClientFactory.CreateClient(NamedClient.Dlna).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var client = _httpClientFactory.CreateClient(NamedClient.Dlna); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using MemoryStream ms = new MemoryStream(); + await response.Content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); try { return await XDocument.LoadAsync( - stream, + ms, LoadOptions.None, cancellationToken).ConfigureAwait(false); } - catch (XmlException ex) + catch (XmlException) { - _logger.LogError(ex, "Failed to parse response"); - if (_logger.IsEnabled(LogLevel.Debug)) + // try correcting the Xml response with common errors + ms.Position = 0; + using StreamReader sr = new StreamReader(ms); + var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + + // find and replace unescaped ampersands (&) + xmlString = EscapeAmpersandRegex().Replace(xmlString, "&"); + + try { - _logger.LogDebug("Malformed response: {Content}\n", await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + // retry reading Xml + using var xmlReader = new StringReader(xmlString); + return await XDocument.LoadAsync( + xmlReader, + LoadOptions.None, + cancellationToken).ConfigureAwait(false); } + catch (XmlException ex) + { + _logger.LogError(ex, "Failed to parse response"); + _logger.LogDebug("Malformed response: {Content}\n", xmlString); - return null; + return null; + } } } @@ -104,5 +128,12 @@ namespace Emby.Dlna.PlayTo // Have to await here instead of returning the Task directly, otherwise request would be disposed too soon return await SendRequestAsync(request, cancellationToken).ConfigureAwait(false); } + + /// <summary> + /// Compile-time generated regular expression for escaping ampersands. + /// </summary> + /// <returns>Compiled regular expression.</returns> + [GeneratedRegex("(&(?![a-z]*;))")] + private static partial Regex EscapeAmpersandRegex(); } } diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index 7b1f942c5a..86db363374 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -66,7 +64,8 @@ namespace Emby.Dlna.PlayTo IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, - IMediaEncoder mediaEncoder) + IMediaEncoder mediaEncoder, + Device device) { _session = session; _sessionManager = sessionManager; @@ -82,14 +81,7 @@ namespace Emby.Dlna.PlayTo _localization = localization; _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; - } - - public bool IsSessionActive => !_disposed && _device is not null; - public bool SupportsMediaControl => IsSessionActive; - - public void Init(Device device) - { _device = device; _device.OnDeviceUnavailable = OnDeviceUnavailable; _device.PlaybackStart += OnDevicePlaybackStart; @@ -102,6 +94,10 @@ namespace Emby.Dlna.PlayTo _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft; } + public bool IsSessionActive => !_disposed; + + public bool SupportsMediaControl => IsSessionActive; + /* * Send a message to the DLNA device to notify what is the next track in the playlist. */ @@ -131,22 +127,22 @@ namespace Emby.Dlna.PlayTo } } - private void OnDeviceDiscoveryDeviceLeft(object sender, GenericEventArgs<UpnpDeviceInfo> e) + private void OnDeviceDiscoveryDeviceLeft(object? sender, GenericEventArgs<UpnpDeviceInfo> e) { var info = e.Argument; if (!_disposed - && info.Headers.TryGetValue("USN", out string usn) + && info.Headers.TryGetValue("USN", out string? usn) && usn.IndexOf(_device.Properties.UUID, StringComparison.OrdinalIgnoreCase) != -1 && (usn.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) != -1 - || (info.Headers.TryGetValue("NT", out string nt) + || (info.Headers.TryGetValue("NT", out string? nt) && nt.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) != -1))) { OnDeviceUnavailable(); } } - private async void OnDeviceMediaChanged(object sender, MediaChangedEventArgs e) + private async void OnDeviceMediaChanged(object? sender, MediaChangedEventArgs e) { if (_disposed || string.IsNullOrEmpty(e.OldMediaInfo.Url)) { @@ -188,7 +184,7 @@ namespace Emby.Dlna.PlayTo } } - private async void OnDevicePlaybackStopped(object sender, PlaybackStoppedEventArgs e) + private async void OnDevicePlaybackStopped(object? sender, PlaybackStoppedEventArgs e) { if (_disposed) { @@ -257,7 +253,7 @@ namespace Emby.Dlna.PlayTo } } - private async void OnDevicePlaybackStart(object sender, PlaybackStartEventArgs e) + private async void OnDevicePlaybackStart(object? sender, PlaybackStartEventArgs e) { if (_disposed) { @@ -281,7 +277,7 @@ namespace Emby.Dlna.PlayTo } } - private async void OnDevicePlaybackProgress(object sender, PlaybackProgressEventArgs e) + private async void OnDevicePlaybackProgress(object? sender, PlaybackProgressEventArgs e) { if (_disposed) { @@ -486,9 +482,9 @@ namespace Emby.Dlna.PlayTo private PlaylistItem CreatePlaylistItem( BaseItem item, - User user, + User? user, long startPostionTicks, - string mediaSourceId, + string? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex) { @@ -525,7 +521,7 @@ namespace Emby.Dlna.PlayTo return playlistItem; } - private string GetDlnaHeaders(PlaylistItem item) + private string? GetDlnaHeaders(PlaylistItem item) { var profile = item.Profile; var streamInfo = item.StreamInfo; @@ -579,7 +575,7 @@ namespace Emby.Dlna.PlayTo return null; } - private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex) + private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex) { if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) { @@ -696,7 +692,6 @@ namespace Emby.Dlna.PlayTo _device.MediaChanged -= OnDeviceMediaChanged; _deviceDiscovery.DeviceLeft -= OnDeviceDiscoveryDeviceLeft; _device.OnDeviceUnavailable = null; - _device = null; _disposed = true; } @@ -716,7 +711,7 @@ namespace Emby.Dlna.PlayTo case GeneralCommandType.ToggleMute: return _device.ToggleMute(cancellationToken); case GeneralCommandType.SetAudioStreamIndex: - if (command.Arguments.TryGetValue("Index", out string index)) + if (command.Arguments.TryGetValue("Index", out string? index)) { if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) { @@ -740,7 +735,7 @@ namespace Emby.Dlna.PlayTo throw new ArgumentException("SetSubtitleStreamIndex argument cannot be null"); case GeneralCommandType.SetVolume: - if (command.Arguments.TryGetValue("Volume", out string vol)) + if (command.Arguments.TryGetValue("Volume", out string? vol)) { if (int.TryParse(vol, NumberStyles.Integer, CultureInfo.InvariantCulture, out var volume)) { @@ -865,34 +860,19 @@ namespace Emby.Dlna.PlayTo throw new ObjectDisposedException(GetType().Name); } - if (_device is null) - { - return Task.CompletedTask; - } - - if (name == SessionMessageType.Play) - { - return SendPlayCommand(data as PlayRequest, cancellationToken); - } - - if (name == SessionMessageType.Playstate) + return name switch { - return SendPlaystateCommand(data as PlaystateRequest, cancellationToken); - } - - if (name == SessionMessageType.GeneralCommand) - { - return SendGeneralCommand(data as GeneralCommand, cancellationToken); - } - - // Not supported or needed right now - return Task.CompletedTask; + SessionMessageType.Play => SendPlayCommand((data as PlayRequest)!, cancellationToken), + SessionMessageType.Playstate => SendPlaystateCommand((data as PlaystateRequest)!, cancellationToken), + SessionMessageType.GeneralCommand => SendGeneralCommand((data as GeneralCommand)!, cancellationToken), + _ => Task.CompletedTask // Not supported or needed right now + }; } private class StreamParams { - private MediaSourceInfo _mediaSource; - private IMediaSourceManager _mediaSourceManager; + private MediaSourceInfo? _mediaSource; + private IMediaSourceManager? _mediaSourceManager; public Guid ItemId { get; set; } @@ -904,17 +884,17 @@ namespace Emby.Dlna.PlayTo public int? SubtitleStreamIndex { get; set; } - public string DeviceProfileId { get; set; } + public string? DeviceProfileId { get; set; } - public string DeviceId { get; set; } + public string? DeviceId { get; set; } - public string MediaSourceId { get; set; } + public string? MediaSourceId { get; set; } - public string LiveStreamId { get; set; } + public string? LiveStreamId { get; set; } - public BaseItem Item { get; set; } + public BaseItem? Item { get; set; } - public async Task<MediaSourceInfo> GetMediaSource(CancellationToken cancellationToken) + public async Task<MediaSourceInfo?> GetMediaSource(CancellationToken cancellationToken) { if (_mediaSource is not null) { @@ -944,8 +924,8 @@ namespace Emby.Dlna.PlayTo { var part = parts[i]; - if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) || - string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) + || string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase)) { if (Guid.TryParse(parts[i + 1], out var result)) { diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs index f4a9a90af4..b469c9cb06 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -205,12 +205,11 @@ namespace Emby.Dlna.PlayTo _userDataManager, _localization, _mediaSourceManager, - _mediaEncoder); + _mediaEncoder, + device); sessionInfo.AddController(controller); - controller.Init(device); - var profile = _dlnaManager.GetProfile(device.Properties.ToDeviceIdentification()) ?? _dlnaManager.GetDefaultProfile(); diff --git a/Emby.Dlna/PlayTo/TransportCommands.cs b/Emby.Dlna/PlayTo/TransportCommands.cs index c463727329..6b2096d9dc 100644 --- a/Emby.Dlna/PlayTo/TransportCommands.cs +++ b/Emby.Dlna/PlayTo/TransportCommands.cs @@ -116,7 +116,7 @@ namespace Emby.Dlna.PlayTo return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamespace, stateString); } - public string BuildPost(ServiceAction action, string xmlNamesapce, object value, string commandParameter = "") + public string BuildPost(ServiceAction action, string xmlNamespace, object value, string commandParameter = "") { var stateString = string.Empty; @@ -137,10 +137,10 @@ namespace Emby.Dlna.PlayTo } } - return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString); + return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamespace, stateString); } - public string BuildPost(ServiceAction action, string xmlNamesapce, object value, Dictionary<string, string> dictionary) + public string BuildPost(ServiceAction action, string xmlNamespace, object value, Dictionary<string, string> dictionary) { var stateString = string.Empty; @@ -150,9 +150,9 @@ namespace Emby.Dlna.PlayTo { stateString += BuildArgumentXml(arg, "0"); } - else if (dictionary.ContainsKey(arg.Name)) + else if (dictionary.TryGetValue(arg.Name, out var argValue)) { - stateString += BuildArgumentXml(arg, dictionary[arg.Name]); + stateString += BuildArgumentXml(arg, argValue); } else { @@ -160,7 +160,7 @@ namespace Emby.Dlna.PlayTo } } - return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString); + return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamespace, stateString); } private string BuildArgumentXml(Argument argument, string? value, string commandParameter = "") diff --git a/Emby.Dlna/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs index d00df781d6..69ef6f6456 100644 --- a/Emby.Dlna/Server/DescriptionXmlBuilder.cs +++ b/Emby.Dlna/Server/DescriptionXmlBuilder.cs @@ -147,11 +147,16 @@ namespace Emby.Dlna.Server } } - private string GetFriendlyName() + internal string GetFriendlyName() { if (string.IsNullOrEmpty(_profile.FriendlyName)) { - return "Jellyfin - " + _serverName; + return _serverName; + } + + if (!_profile.FriendlyName.Contains("${HostName}", StringComparison.OrdinalIgnoreCase)) + { + return _profile.FriendlyName; } var characterList = new List<char>(); @@ -164,13 +169,18 @@ namespace Emby.Dlna.Server } } - var characters = characterList.ToArray(); - - var serverName = new string(characters); - - var name = _profile.FriendlyName?.Replace("${HostName}", serverName, StringComparison.OrdinalIgnoreCase); + var serverName = string.Create( + characterList.Count, + characterList, + (dest, source) => + { + for (int i = 0; i < dest.Length; i++) + { + dest[i] = source[i]; + } + }); - return name ?? string.Empty; + return _profile.FriendlyName.Replace("${HostName}", serverName, StringComparison.OrdinalIgnoreCase); } private void AppendIconList(StringBuilder builder) diff --git a/Emby.Naming/Audio/AlbumParser.cs b/Emby.Naming/Audio/AlbumParser.cs index bbfdccc902..86a5641531 100644 --- a/Emby.Naming/Audio/AlbumParser.cs +++ b/Emby.Naming/Audio/AlbumParser.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.IO; using System.Text.RegularExpressions; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Audio { @@ -58,13 +59,7 @@ namespace Emby.Naming.Audio var tmp = trimmedFilename.Slice(prefix.Length).Trim(); - int index = tmp.IndexOf(' '); - if (index != -1) - { - tmp = tmp.Slice(0, index); - } - - if (int.TryParse(tmp, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + if (int.TryParse(tmp.LeftPart(' '), CultureInfo.InvariantCulture, out _)) { return true; } diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs index 7b4429ab15..75fdedfeab 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs @@ -32,7 +32,7 @@ namespace Emby.Naming.AudioBook var fileName = Path.GetFileNameWithoutExtension(path); foreach (var expression in _options.AudioBookPartsExpressions) { - var match = new Regex(expression, RegexOptions.IgnoreCase).Match(fileName); + var match = Regex.Match(fileName, expression, RegexOptions.IgnoreCase); if (match.Success) { if (!result.ChapterNumber.HasValue) @@ -40,7 +40,7 @@ namespace Emby.Naming.AudioBook var value = match.Groups["chapter"]; if (value.Success) { - if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { result.ChapterNumber = intValue; } @@ -52,7 +52,7 @@ namespace Emby.Naming.AudioBook var value = match.Groups["part"]; if (value.Success) { - if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { result.PartNumber = intValue; } diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index bdae20b6b2..ca304102fd 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -79,25 +79,25 @@ namespace Emby.Naming.AudioBook { if (group.Count() > 1 || haveChaptersOrPages) { - var ex = new List<AudioBookFileInfo>(); - var alt = new List<AudioBookFileInfo>(); + List<AudioBookFileInfo>? ex = null; + List<AudioBookFileInfo>? alt = null; foreach (var audioFile in group) { - var name = Path.GetFileNameWithoutExtension(audioFile.Path); - if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) || - name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) || - name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) + var name = Path.GetFileNameWithoutExtension(audioFile.Path.AsSpan()); + if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) + || name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) + || name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) { - alt.Add(audioFile); + (alt ??= new()).Add(audioFile); } else { - ex.Add(audioFile); + (ex ??= new()).Add(audioFile); } } - if (ex.Count > 0) + if (ex is not null) { var extra = ex .OrderBy(x => x.Container) @@ -108,7 +108,7 @@ namespace Emby.Naming.AudioBook extras.AddRange(extra); } - if (alt.Count > 0) + if (alt is not null) { var alternatives = alt .OrderBy(x => x.Container) diff --git a/Emby.Naming/AudioBook/AudioBookNameParser.cs b/Emby.Naming/AudioBook/AudioBookNameParser.cs index 97b34199e0..5ea649dbf7 100644 --- a/Emby.Naming/AudioBook/AudioBookNameParser.cs +++ b/Emby.Naming/AudioBook/AudioBookNameParser.cs @@ -30,7 +30,7 @@ namespace Emby.Naming.AudioBook AudioBookNameParserResult result = default; foreach (var expression in _options.AudioBookNamesExpressions) { - var match = new Regex(expression, RegexOptions.IgnoreCase).Match(name); + var match = Regex.Match(name, expression, RegexOptions.IgnoreCase); if (match.Success) { if (result.Name is null) @@ -47,7 +47,7 @@ namespace Emby.Naming.AudioBook var value = match.Groups["year"]; if (value.Success) { - if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { result.Year = intValue; } diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 54f62a1570..a069da1022 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -141,8 +141,7 @@ namespace Emby.Naming.Common VideoFileStackingRules = new[] { new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true), - new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false), - new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]?)(?<number>[a-d])(?:\.[^.]+)?$", false) + new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false) }; CleanDateTimes = new[] @@ -157,7 +156,8 @@ namespace Emby.Naming.Common @"^(?<cleaned>.+?)(\[.*\])", @"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)", - @"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$" + @"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$", + @"^\s*(?<cleaned>.+?)(([-._ ](trailer|sample))|-(scene|clip|behindthescenes|deleted|deletedscene|featurette|short|interview|other|extra))$" }; SubtitleFileExtensions = new[] @@ -270,7 +270,6 @@ namespace Emby.Naming.Common ".sfx", ".shn", ".sid", - ".spc", ".stm", ".strm", ".ult", @@ -338,7 +337,15 @@ namespace Emby.Naming.Common } }, - // This isn't a Kodi naming rule, but the expression below causes false positives, + // This isn't a Kodi naming rule, but the expression below causes false episode numbers for + // Title Season X Episode X naming schemes. + // "Series Season X Episode X - Title.avi", "Series S03 E09.avi", "s3 e9 - Title.avi" + new EpisodeExpression(@".*[\\\/]((?<seriesname>[^\\/]+?)\s)?[Ss](?:eason)?\s*(?<seasonnumber>[0-9]+)\s+[Ee](?:pisode)?\s*(?<epnumber>[0-9]+).*$") + { + IsNamed = true + }, + + // Not a Kodi rule as well, but the expression below also causes false positives, // so we make sure this one gets tested first. // "Foo Bar 889" new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$") @@ -453,16 +460,6 @@ namespace Emby.Naming.Common }, }; - EpisodeWithoutSeasonExpressions = new[] - { - @"[/\._ \-]()([0-9]+)(-[0-9]+)?" - }; - - EpisodeMultiPartExpressions = new[] - { - @"^[-_ex]+([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)" - }; - VideoExtraRules = new[] { new ExtraRule( @@ -798,16 +795,6 @@ namespace Emby.Naming.Common public EpisodeExpression[] EpisodeExpressions { get; set; } /// <summary> - /// Gets or sets list of raw episode without season regular expressions strings. - /// </summary> - public string[] EpisodeWithoutSeasonExpressions { get; set; } - - /// <summary> - /// Gets or sets list of raw multi-part episodes regular expressions strings. - /// </summary> - public string[] EpisodeMultiPartExpressions { get; set; } - - /// <summary> /// Gets or sets list of video file extensions. /// </summary> public string[] VideoFileExtensions { get; set; } @@ -878,24 +865,12 @@ namespace Emby.Naming.Common public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>(); /// <summary> - /// Gets list of episode without season regular expressions. - /// </summary> - public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } = Array.Empty<Regex>(); - - /// <summary> - /// Gets list of multi-part episode regular expressions. - /// </summary> - public Regex[] EpisodeMultiPartRegexes { get; private set; } = Array.Empty<Regex>(); - - /// <summary> /// Compiles raw regex strings into regexes. /// </summary> public void Compile() { CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray(); CleanStringRegexes = CleanStrings.Select(Compile).ToArray(); - EpisodeWithoutSeasonRegexes = EpisodeWithoutSeasonExpressions.Select(Compile).ToArray(); - EpisodeMultiPartRegexes = EpisodeMultiPartExpressions.Select(Compile).ToArray(); } private Regex Compile(string exp) diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 3106e22465..f3973dad95 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -42,18 +42,18 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" /> </ItemGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index d706be2802..8cd5a126e0 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -113,7 +113,7 @@ namespace Emby.Naming.TV if (expression.DateTimeFormats.Length > 0) { if (DateTime.TryParseExact( - match.Groups[0].Value, + match.Groups[0].ValueSpan, expression.DateTimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, @@ -125,7 +125,7 @@ namespace Emby.Naming.TV result.Success = true; } } - else if (DateTime.TryParse(match.Groups[0].Value, out date)) + else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date)) { result.Year = date.Year; result.Month = date.Month; @@ -138,12 +138,12 @@ namespace Emby.Naming.TV } else if (expression.IsNamed) { - if (int.TryParse(match.Groups["seasonnumber"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(match.Groups["seasonnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) { result.SeasonNumber = num; } - if (int.TryParse(match.Groups["epnumber"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + if (int.TryParse(match.Groups["epnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { result.EpisodeNumber = num; } @@ -158,7 +158,7 @@ namespace Emby.Naming.TV if (nextIndex >= name.Length || !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal)) { - if (int.TryParse(endingNumberGroup.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { result.EndingEpisodeNumber = num; } @@ -170,12 +170,12 @@ namespace Emby.Naming.TV } else { - if (int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(match.Groups[1].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) { result.SeasonNumber = num; } - if (int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + if (int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { result.EpisodeNumber = num; } diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index 156a03c9ed..307a840964 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -14,7 +14,7 @@ namespace Emby.Naming.TV /// Used for removing separators between words, i.e turns "The_show" into "The show" while /// preserving namings like "S.H.O.W". /// </summary> - private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))"); + private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))", RegexOptions.Compiled); /// <summary> /// Resolve information about series from path. diff --git a/Emby.Naming/Video/CleanDateTimeParser.cs b/Emby.Naming/Video/CleanDateTimeParser.cs index 0ee633dcc6..9a6c6e978b 100644 --- a/Emby.Naming/Video/CleanDateTimeParser.cs +++ b/Emby.Naming/Video/CleanDateTimeParser.cs @@ -43,7 +43,7 @@ namespace Emby.Naming.Video && match.Groups.Count == 5 && match.Groups[1].Success && match.Groups[2].Success - && int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) + && int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) { result = new CleanDateTimeResult(match.Groups[1].Value.TrimEnd(), year); return true; diff --git a/Emby.Naming/Video/ExtraRuleResolver.cs b/Emby.Naming/Video/ExtraRuleResolver.cs index 21d0da3642..3219472eff 100644 --- a/Emby.Naming/Video/ExtraRuleResolver.cs +++ b/Emby.Naming/Video/ExtraRuleResolver.cs @@ -56,7 +56,7 @@ namespace Emby.Naming.Video } else if (rule.RuleType == ExtraRuleType.Regex) { - var filename = Path.GetFileName(path); + var filename = Path.GetFileName(path.AsSpan()); var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled); diff --git a/Emby.Naming/Video/FileStackRule.cs b/Emby.Naming/Video/FileStackRule.cs index 76b487f428..be0f79d33a 100644 --- a/Emby.Naming/Video/FileStackRule.cs +++ b/Emby.Naming/Video/FileStackRule.cs @@ -17,7 +17,7 @@ public class FileStackRule /// <param name="isNumerical">Whether the file stack rule uses numerical or alphabetical numbering.</param> public FileStackRule(string token, bool isNumerical) { - _tokenRegex = new Regex(token, RegexOptions.IgnoreCase); + _tokenRegex = new Regex(token, RegexOptions.IgnoreCase | RegexOptions.Compiled); IsNumerical = isNumerical; } diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 8048320400..6209cd46f4 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; +using Jellyfin.Extensions; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -13,6 +14,8 @@ namespace Emby.Naming.Video /// </summary> public static class VideoListResolver { + private static readonly Regex _resolutionRegex = new Regex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase | RegexOptions.Compiled); + /// <summary> /// Resolves alternative versions and extras from list of video files. /// </summary> @@ -106,6 +109,7 @@ namespace Emby.Naming.Video } // Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if] + VideoInfo? primary = null; for (var i = 0; i < videos.Count; i++) { var video = videos[i]; @@ -114,29 +118,43 @@ namespace Emby.Naming.Video continue; } - if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions)) + if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) { return videos; } + + if (folderName.Equals(video.Files[0].FileNameWithoutExtension, StringComparison.Ordinal)) + { + primary = video; + } + } + + if (videos.Count > 1) + { + var groups = videos.GroupBy(x => _resolutionRegex.IsMatch(x.Files[0].FileNameWithoutExtension)).ToList(); + videos.Clear(); + foreach (var group in groups) + { + if (group.Key) + { + videos.InsertRange(0, group.OrderByDescending(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); + } + else + { + videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); + } + } } - // The list is created and overwritten in the caller, so we are allowed to do in-place sorting - videos.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.Ordinal)); + primary ??= videos[0]; + videos.Remove(primary); var list = new List<VideoInfo> { - videos[0] + primary }; - var alternateVersionsLen = videos.Count - 1; - var alternateVersions = new VideoFileInfo[alternateVersionsLen]; - for (int i = 0; i < alternateVersionsLen; i++) - { - var video = videos[i + 1]; - alternateVersions[i] = video.Files[0]; - } - - list[0].AlternateVersions = alternateVersions; + list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray(); list[0].Name = folderName.ToString(); return list; @@ -161,9 +179,8 @@ namespace Emby.Naming.Video return true; } - private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, string testFilePath, NamingOptions namingOptions) + private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions) { - var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan()); if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { return false; @@ -176,16 +193,15 @@ namespace Emby.Naming.Video } // There are no span overloads for regex unfortunately - var tmpTestFilename = testFilename.ToString(); - if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName)) + if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName)) { - tmpTestFilename = cleanName.Trim(); + testFilename = cleanName.AsSpan().Trim(); } // The CleanStringParser should have removed common keywords etc. - return string.IsNullOrEmpty(tmpTestFilename) + return testFilename.IsEmpty || testFilename[0] == '-' - || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); + || Regex.IsMatch(testFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); } } } diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index 858e9dd2f5..db5bfdbf94 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -87,8 +87,7 @@ namespace Emby.Naming.Video name = cleanDateTimeResult.Name; year = cleanDateTimeResult.Year; - if (extraResult.ExtraType is null - && TryCleanString(name, namingOptions, out var newName)) + if (TryCleanString(name, namingOptions, out var newName)) { name = newName; } diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index ae6bc2db1f..0f97a06867 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -15,7 +15,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="TagLibSharp" Version="2.3.0" /> + <PackageReference Include="TagLibSharp" /> </ItemGroup> <PropertyGroup> @@ -26,13 +26,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 985a127d50..a4deeddb78 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -34,14 +32,9 @@ namespace Emby.Server.Implementations.AppBase private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>(); /// <summary> - /// The _configuration loaded. - /// </summary> - private bool _configurationLoaded; - - /// <summary> /// The _configuration. /// </summary> - private BaseApplicationConfiguration _configuration; + private BaseApplicationConfiguration? _configuration; /// <summary> /// Initializes a new instance of the <see cref="BaseConfigurationManager" /> class. @@ -63,17 +56,17 @@ namespace Emby.Server.Implementations.AppBase /// <summary> /// Occurs when [configuration updated]. /// </summary> - public event EventHandler<EventArgs> ConfigurationUpdated; + public event EventHandler<EventArgs>? ConfigurationUpdated; /// <summary> /// Occurs when [configuration updating]. /// </summary> - public event EventHandler<ConfigurationUpdateEventArgs> NamedConfigurationUpdating; + public event EventHandler<ConfigurationUpdateEventArgs>? NamedConfigurationUpdating; /// <summary> /// Occurs when [named configuration updated]. /// </summary> - public event EventHandler<ConfigurationUpdateEventArgs> NamedConfigurationUpdated; + public event EventHandler<ConfigurationUpdateEventArgs>? NamedConfigurationUpdated; /// <summary> /// Gets the type of the configuration. @@ -107,31 +100,25 @@ namespace Emby.Server.Implementations.AppBase { get { - if (_configurationLoaded) + if (_configuration is not null) { return _configuration; } lock (_configurationSyncLock) { - if (_configurationLoaded) + if (_configuration is not null) { return _configuration; } - _configuration = (BaseApplicationConfiguration)ConfigurationHelper.GetXmlConfiguration(ConfigurationType, CommonApplicationPaths.SystemConfigurationFilePath, XmlSerializer); - - _configurationLoaded = true; - - return _configuration; + return _configuration = (BaseApplicationConfiguration)ConfigurationHelper.GetXmlConfiguration(ConfigurationType, CommonApplicationPaths.SystemConfigurationFilePath, XmlSerializer); } } protected set { _configuration = value; - - _configurationLoaded = value is not null; } } @@ -183,7 +170,7 @@ namespace Emby.Server.Implementations.AppBase Logger.LogInformation("Saving system configuration"); var path = CommonApplicationPaths.SystemConfigurationFilePath; - Directory.CreateDirectory(Path.GetDirectoryName(path)); + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory.")); lock (_configurationSyncLock) { @@ -323,25 +310,20 @@ namespace Emby.Server.Implementations.AppBase private object LoadConfiguration(string path, Type configurationType) { - if (!File.Exists(path)) - { - return Activator.CreateInstance(configurationType); - } - try { - return XmlSerializer.DeserializeFromFile(configurationType, path); - } - catch (IOException) - { - return Activator.CreateInstance(configurationType); + if (File.Exists(path)) + { + return XmlSerializer.DeserializeFromFile(configurationType, path); + } } - catch (Exception ex) + catch (Exception ex) when (ex is not IOException) { Logger.LogError(ex, "Error loading configuration file: {Path}", path); - - return Activator.CreateInstance(configurationType); } + + return Activator.CreateInstance(configurationType) + ?? throw new InvalidOperationException("Configuration type can't be Nullable<T>."); } /// <inheritdoc /> @@ -367,7 +349,7 @@ namespace Emby.Server.Implementations.AppBase _configurations.AddOrUpdate(key, configuration, (_, _) => configuration); var path = GetConfigurationFile(key); - Directory.CreateDirectory(Path.GetDirectoryName(path)); + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory.")); lock (_configurationSyncLock) { diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 07b0807b72..7969577bc0 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -11,7 +11,6 @@ using System.IO; using System.Linq; using System.Net; using System.Reflection; -using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; @@ -81,11 +80,13 @@ using MediaBrowser.Controller.Subtitles; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; +using MediaBrowser.MediaEncoding.BdInfo; using MediaBrowser.MediaEncoding.Subtitles; using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; @@ -114,14 +115,10 @@ namespace Emby.Server.Implementations public abstract class ApplicationHost : IServerApplicationHost, IAsyncDisposable, IDisposable { /// <summary> - /// The environment variable prefixes to log at server startup. - /// </summary> - private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; - - /// <summary> /// The disposable parts. /// </summary> private readonly ConcurrentDictionary<IDisposable, byte> _disposableParts = new(); + private readonly DeviceId _deviceId; private readonly IFileSystem _fileSystemManager; private readonly IConfiguration _startupConfig; @@ -130,7 +127,6 @@ namespace Emby.Server.Implementations private readonly IPluginManager _pluginManager; private List<Type> _creatingInstances; - private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; /// <summary> @@ -139,8 +135,6 @@ namespace Emby.Server.Implementations /// <value>All concrete types.</value> private Type[] _allConcreteTypes; - private DeviceId _deviceId; - private bool _disposed = false; /// <summary> @@ -164,6 +158,7 @@ namespace Emby.Server.Implementations Logger = LoggerFactory.CreateLogger<ApplicationHost>(); _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager)); + _deviceId = new DeviceId(ApplicationPaths, LoggerFactory); ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; ApplicationVersionString = ApplicationVersion.ToString(3); @@ -191,23 +186,9 @@ namespace Emby.Server.Implementations public bool CoreStartupHasCompleted { get; private set; } - public virtual bool CanLaunchWebBrowser - { - get - { - if (!Environment.UserInteractive) - { - return false; - } - - if (_startupOptions.IsService) - { - return false; - } - - return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); - } - } + public virtual bool CanLaunchWebBrowser => Environment.UserInteractive + && !_startupOptions.IsService + && (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()); /// <summary> /// Gets the <see cref="INetworkManager"/> singleton instance. @@ -284,15 +265,7 @@ namespace Emby.Server.Implementations /// <value>The application name.</value> public string ApplicationProductName { get; } = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductName; - public string SystemId - { - get - { - _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory); - - return _deviceId.Value; - } - } + public string SystemId => _deviceId.Value; /// <inheritdoc/> public string Name => ApplicationProductName; @@ -445,7 +418,7 @@ namespace Emby.Server.Implementations ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated; - _mediaEncoder.SetFFmpegPath(); + Resolve<IMediaEncoder>().SetFFmpegPath(); Logger.LogInformation("ServerId: {ServerId}", SystemId); @@ -558,6 +531,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); + serviceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); + serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>(); serviceCollection.AddSingleton<IUserDataManager, UserDataManager>(); @@ -652,50 +627,19 @@ namespace Emby.Server.Implementations } } + ((SqliteItemRepository)Resolve<IItemRepository>()).Initialize(); + ((SqliteUserDataRepository)Resolve<IUserDataRepository>()).Initialize(); + var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>(); await localizationManager.LoadAll().ConfigureAwait(false); - _mediaEncoder = Resolve<IMediaEncoder>(); _sessionManager = Resolve<ISessionManager>(); SetStaticProperties(); - var userDataRepo = (SqliteUserDataRepository)Resolve<IUserDataRepository>(); - ((SqliteItemRepository)Resolve<IItemRepository>()).Initialize(userDataRepo, Resolve<IUserManager>()); - FindParts(); } - public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths) - { - // Distinct these to prevent users from reporting problems that aren't actually problems - var commandLineArgs = Environment - .GetCommandLineArgs() - .Distinct(); - - // Get all relevant environment variables - var allEnvVars = Environment.GetEnvironmentVariables(); - var relevantEnvVars = new Dictionary<object, object>(); - foreach (var key in allEnvVars.Keys) - { - if (_relevantEnvVarPrefixes.Any(prefix => key.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) - { - relevantEnvVars.Add(key, allEnvVars[key]); - } - } - - logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); - logger.LogInformation("Arguments: {Args}", commandLineArgs); - logger.LogInformation("Operating system: {OS}", RuntimeInformation.OSDescription); - logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture); - logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess); - logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive); - logger.LogInformation("Processor count: {ProcessorCount}", Environment.ProcessorCount); - logger.LogInformation("Program data path: {ProgramDataPath}", appPaths.ProgramDataPath); - logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath); - logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); - } - private X509Certificate2 GetCertificate(string path, string password) { if (string.IsNullOrWhiteSpace(path)) @@ -782,10 +726,6 @@ namespace Emby.Server.Implementations Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<ITunerHost>(), GetExports<IListingsProvider>()); - Resolve<ISubtitleManager>().AddParts(GetExports<ISubtitleProvider>()); - - Resolve<IChannelManager>().AddParts(GetExports<IChannel>()); - Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); } @@ -1248,10 +1188,13 @@ namespace Emby.Server.Implementations } } - // used for closing websockets - foreach (var session in _sessionManager.Sessions) + if (_sessionManager != null) { - await session.DisposeAsync().ConfigureAwait(false); + // used for closing websockets + foreach (var session in _sessionManager.Sessions) + { + await session.DisposeAsync().ConfigureAwait(false); + } } } } diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 85ccbc0284..961e225e9e 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -66,6 +66,7 @@ namespace Emby.Server.Implementations.Channels /// <param name="userDataManager">The user data manager.</param> /// <param name="providerManager">The provider manager.</param> /// <param name="memoryCache">The memory cache.</param> + /// <param name="channels">The channels.</param> public ChannelManager( IUserManager userManager, IDtoService dtoService, @@ -75,7 +76,8 @@ namespace Emby.Server.Implementations.Channels IFileSystem fileSystem, IUserDataManager userDataManager, IProviderManager providerManager, - IMemoryCache memoryCache) + IMemoryCache memoryCache, + IEnumerable<IChannel> channels) { _userManager = userManager; _dtoService = dtoService; @@ -86,19 +88,14 @@ namespace Emby.Server.Implementations.Channels _userDataManager = userDataManager; _providerManager = providerManager; _memoryCache = memoryCache; + Channels = channels.ToArray(); } - internal IChannel[] Channels { get; private set; } + internal IChannel[] Channels { get; } private static TimeSpan CacheLength => TimeSpan.FromHours(3); /// <inheritdoc /> - public void AddParts(IEnumerable<IChannel> channels) - { - Channels = channels.ToArray(); - } - - /// <inheritdoc /> public bool EnableMediaSourceDisplay(BaseItem item) { var internalChannel = _libraryManager.GetItemById(item.ChannelId); @@ -160,16 +157,16 @@ namespace Emby.Server.Implementations.Channels } /// <inheritdoc /> - public QueryResult<Channel> GetChannelsInternal(ChannelQuery query) + public async Task<QueryResult<Channel>> GetChannelsInternalAsync(ChannelQuery query) { var user = query.UserId.Equals(default) ? null : _userManager.GetUserById(query.UserId); - var channels = GetAllChannels() - .Select(GetChannelEntity) + var channels = await GetAllChannelEntitiesAsync() .OrderBy(i => i.SortName) - .ToList(); + .ToListAsync() + .ConfigureAwait(false); if (query.IsRecordingsFolder.HasValue) { @@ -229,6 +226,7 @@ namespace Emby.Server.Implementations.Channels if (user is not null) { + var userId = user.Id.ToString("N", CultureInfo.InvariantCulture); channels = channels.Where(i => { if (!i.IsVisible(user)) @@ -238,7 +236,7 @@ namespace Emby.Server.Implementations.Channels try { - return GetChannelProvider(i).IsEnabledFor(user.Id.ToString("N", CultureInfo.InvariantCulture)); + return GetChannelProvider(i).IsEnabledFor(userId); } catch { @@ -261,7 +259,7 @@ namespace Emby.Server.Implementations.Channels { foreach (var item in all) { - RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult(); + await RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).ConfigureAwait(false); } } @@ -272,13 +270,13 @@ namespace Emby.Server.Implementations.Channels } /// <inheritdoc /> - public QueryResult<BaseItemDto> GetChannels(ChannelQuery query) + public async Task<QueryResult<BaseItemDto>> GetChannelsAsync(ChannelQuery query) { var user = query.UserId.Equals(default) ? null : _userManager.GetUserById(query.UserId); - var internalResult = GetChannelsInternal(query); + var internalResult = await GetChannelsInternalAsync(query).ConfigureAwait(false); var dtoOptions = new DtoOptions(); @@ -330,9 +328,12 @@ namespace Emby.Server.Implementations.Channels progress.Report(100); } - private Channel GetChannelEntity(IChannel channel) + private async IAsyncEnumerable<Channel> GetAllChannelEntitiesAsync() { - return GetChannel(GetInternalChannelId(channel.Name)) ?? GetChannel(channel, CancellationToken.None).GetAwaiter().GetResult(); + foreach (IChannel channel in GetAllChannels()) + { + yield return GetChannel(GetInternalChannelId(channel.Name)) ?? await GetChannel(channel, CancellationToken.None).ConfigureAwait(false); + } } private MediaSourceInfo[] GetSavedMediaSources(BaseItem item) @@ -404,7 +405,7 @@ namespace Emby.Server.Implementations.Channels } else { - results = new List<MediaSourceInfo>(); + results = Enumerable.Empty<MediaSourceInfo>(); } return results diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index b53c8ca512..b34d0f21ef 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -112,7 +112,8 @@ namespace Emby.Server.Implementations.Collections return Path.Combine(_appPaths.DataPath, "collections"); } - private Task<Folder?> GetCollectionsFolder(bool createIfNeeded) + /// <inheritdoc /> + public Task<Folder?> GetCollectionsFolder(bool createIfNeeded) { return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } @@ -206,8 +207,7 @@ namespace Emby.Server.Implementations.Collections throw new ArgumentException("No collection exists with the supplied Id"); } - var list = new List<LinkedChild>(); - var itemList = new List<BaseItem>(); + List<BaseItem>? itemList = null; var linkedChildrenList = collection.GetLinkedChildren(); var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList(); @@ -223,18 +223,23 @@ namespace Emby.Server.Implementations.Collections if (!currentLinkedChildrenIds.Contains(id)) { - itemList.Add(item); + (itemList ??= new()).Add(item); - list.Add(LinkedChild.Create(item)); linkedChildrenList.Add(item); } } - if (list.Count > 0) + if (itemList is not null) { - LinkedChild[] newChildren = new LinkedChild[collection.LinkedChildren.Length + list.Count]; + var originalLen = collection.LinkedChildren.Length; + var newItemCount = itemList.Count; + LinkedChild[] newChildren = new LinkedChild[originalLen + newItemCount]; collection.LinkedChildren.CopyTo(newChildren, 0); - list.CopyTo(newChildren, collection.LinkedChildren.Length); + for (int i = 0; i < newItemCount; i++) + { + newChildren[originalLen + i] = LinkedChild.Create(itemList[i]); + } + collection.LinkedChildren = newChildren; collection.UpdateRatingToItems(linkedChildrenList); diff --git a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs index ff5602f243..6b8b1a620f 100644 --- a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Globalization; using System.IO; @@ -36,7 +34,7 @@ namespace Emby.Server.Implementations.Configuration /// <summary> /// Configuration updating event. /// </summary> - public event EventHandler<GenericEventArgs<ServerConfiguration>> ConfigurationUpdating; + public event EventHandler<GenericEventArgs<ServerConfiguration>>? ConfigurationUpdating; /// <summary> /// Gets the type of the configuration. diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index f0a4c8ffbd..f0c2676279 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -11,14 +11,15 @@ namespace Emby.Server.Implementations /// <summary> /// Gets a new copy of the default configuration options. /// </summary> - public static Dictionary<string, string?> DefaultConfiguration => new Dictionary<string, string?> + public static Dictionary<string, string?> DefaultConfiguration => new() { { HostWebClientKey, bool.TrueString }, - { DefaultRedirectKey, "web/index.html" }, + { DefaultRedirectKey, "web/" }, { FfmpegProbeSizeKey, "1G" }, { FfmpegAnalyzeDurationKey, "200M" }, { PlaylistsAllowDuplicatesKey, bool.FalseString }, - { BindToUnixSocketKey, bool.FalseString } + { BindToUnixSocketKey, bool.FalseString }, + { SqliteCacheSizeKey, "20000" } }; } } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index 1d61667f86..d05534ee75 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Threading; using Jellyfin.Extensions; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -27,10 +26,20 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Gets or sets the path to the DB file. /// </summary> - /// <value>Path to the DB file.</value> protected string DbFilePath { get; set; } /// <summary> + /// Gets or sets the number of write connections to create. + /// </summary> + /// <value>Path to the DB file.</value> + protected int WriteConnectionsCount { get; set; } = 1; + + /// <summary> + /// Gets or sets the number of read connections to create. + /// </summary> + protected int ReadConnectionsCount { get; set; } = 1; + + /// <summary> /// Gets the logger. /// </summary> /// <value>The logger.</value> @@ -63,7 +72,7 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Gets the locking mode. <see href="https://www.sqlite.org/pragma.html#pragma_locking_mode" />. /// </summary> - protected virtual string LockingMode => "EXCLUSIVE"; + protected virtual string LockingMode => "NORMAL"; /// <summary> /// Gets the journal mode. <see href="https://www.sqlite.org/pragma.html#pragma_journal_mode" />. @@ -73,9 +82,10 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Gets the journal size limit. <see href="https://www.sqlite.org/pragma.html#pragma_journal_size_limit" />. + /// The default (-1) is overriden to prevent unconstrained WAL size, as reported by users. /// </summary> /// <value>The journal size limit.</value> - protected virtual int? JournalSizeLimit => 0; + protected virtual int? JournalSizeLimit => 134_217_728; // 128MiB /// <summary> /// Gets the page size. @@ -88,7 +98,7 @@ namespace Emby.Server.Implementations.Data /// </summary> /// <value>The temp store mode.</value> /// <see cref="TempStoreMode"/> - protected virtual TempStoreMode TempStore => TempStoreMode.Default; + protected virtual TempStoreMode TempStore => TempStoreMode.Memory; /// <summary> /// Gets the synchronous mode. @@ -101,83 +111,114 @@ namespace Emby.Server.Implementations.Data /// Gets or sets the write lock. /// </summary> /// <value>The write lock.</value> - protected SemaphoreSlim WriteLock { get; set; } = new SemaphoreSlim(1, 1); + protected ConnectionPool WriteConnections { get; set; } /// <summary> /// Gets or sets the write connection. /// </summary> /// <value>The write connection.</value> - protected SQLiteDatabaseConnection WriteConnection { get; set; } + protected ConnectionPool ReadConnections { get; set; } - protected ManagedConnection GetConnection(bool readOnly = false) + public virtual void Initialize() { - WriteLock.Wait(); - if (WriteConnection is not null) + WriteConnections = new ConnectionPool(WriteConnectionsCount, CreateWriteConnection); + ReadConnections = new ConnectionPool(ReadConnectionsCount, CreateReadConnection); + + // Configuration and pragmas can affect VACUUM so it needs to be last. + using (var connection = GetConnection()) { - return new ManagedConnection(WriteConnection, WriteLock); + connection.Execute("VACUUM"); } + } + + protected ManagedConnection GetConnection(bool readOnly = false) + => readOnly ? ReadConnections.GetConnection() : WriteConnections.GetConnection(); - WriteConnection = SQLite3.Open( + protected SQLiteDatabaseConnection CreateWriteConnection() + { + var writeConnection = SQLite3.Open( DbFilePath, DefaultConnectionFlags | ConnectionFlags.Create | ConnectionFlags.ReadWrite, null); if (CacheSize.HasValue) { - WriteConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); + writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); } if (!string.IsNullOrWhiteSpace(LockingMode)) { - WriteConnection.Execute("PRAGMA locking_mode=" + LockingMode); + writeConnection.Execute("PRAGMA locking_mode=" + LockingMode); } if (!string.IsNullOrWhiteSpace(JournalMode)) { - WriteConnection.Execute("PRAGMA journal_mode=" + JournalMode); + writeConnection.Execute("PRAGMA journal_mode=" + JournalMode); } if (JournalSizeLimit.HasValue) { - WriteConnection.Execute("PRAGMA journal_size_limit=" + (int)JournalSizeLimit.Value); + writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); } if (Synchronous.HasValue) { - WriteConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); + writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); } if (PageSize.HasValue) { - WriteConnection.Execute("PRAGMA page_size=" + PageSize.Value); + writeConnection.Execute("PRAGMA page_size=" + PageSize.Value); } - WriteConnection.Execute("PRAGMA temp_store=" + (int)TempStore); - - // Configuration and pragmas can affect VACUUM so it needs to be last. - WriteConnection.Execute("VACUUM"); + writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore); - return new ManagedConnection(WriteConnection, WriteLock); + return writeConnection; } - public IStatement PrepareStatement(ManagedConnection connection, string sql) - => connection.PrepareStatement(sql); + protected SQLiteDatabaseConnection CreateReadConnection() + { + var connection = SQLite3.Open( + DbFilePath, + DefaultConnectionFlags | ConnectionFlags.ReadOnly, + null); - public IStatement PrepareStatement(IDatabaseConnection connection, string sql) - => connection.PrepareStatement(sql); + if (CacheSize.HasValue) + { + connection.Execute("PRAGMA cache_size=" + CacheSize.Value); + } - public IStatement[] PrepareAll(IDatabaseConnection connection, IReadOnlyList<string> sql) - { - int len = sql.Count; - IStatement[] statements = new IStatement[len]; - for (int i = 0; i < len; i++) + if (!string.IsNullOrWhiteSpace(LockingMode)) + { + connection.Execute("PRAGMA locking_mode=" + LockingMode); + } + + if (!string.IsNullOrWhiteSpace(JournalMode)) + { + connection.Execute("PRAGMA journal_mode=" + JournalMode); + } + + if (JournalSizeLimit.HasValue) + { + connection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); + } + + if (Synchronous.HasValue) { - statements[i] = connection.PrepareStatement(sql[i]); + connection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); } - return statements; + connection.Execute("PRAGMA temp_store=" + (int)TempStore); + + return connection; } + public IStatement PrepareStatement(ManagedConnection connection, string sql) + => connection.PrepareStatement(sql); + + public IStatement PrepareStatement(IDatabaseConnection connection, string sql) + => connection.PrepareStatement(sql); + protected bool TableExists(ManagedConnection connection, string name) { return connection.RunInTransaction( @@ -252,22 +293,10 @@ namespace Emby.Server.Implementations.Data if (dispose) { - WriteLock.Wait(); - try - { - WriteConnection?.Dispose(); - } - finally - { - WriteLock.Release(); - } - - WriteLock.Dispose(); + WriteConnections.Dispose(); + ReadConnections.Dispose(); } - WriteConnection = null; - WriteLock = null; - _disposed = true; } } diff --git a/Emby.Server.Implementations/Data/ConnectionPool.cs b/Emby.Server.Implementations/Data/ConnectionPool.cs new file mode 100644 index 0000000000..5ea7e934ff --- /dev/null +++ b/Emby.Server.Implementations/Data/ConnectionPool.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Concurrent; +using SQLitePCL.pretty; + +namespace Emby.Server.Implementations.Data; + +/// <summary> +/// A pool of SQLite Database connections. +/// </summary> +public sealed class ConnectionPool : IDisposable +{ + private readonly BlockingCollection<SQLiteDatabaseConnection> _connections = new(); + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="ConnectionPool" /> class. + /// </summary> + /// <param name="count">The number of database connection to create.</param> + /// <param name="factory">Factory function to create the database connections.</param> + public ConnectionPool(int count, Func<SQLiteDatabaseConnection> factory) + { + for (int i = 0; i < count; i++) + { + _connections.Add(factory.Invoke()); + } + } + + /// <summary> + /// Gets a database connection from the pool if one is available, otherwise blocks. + /// </summary> + /// <returns>A database connection.</returns> + public ManagedConnection GetConnection() + { + if (_disposed) + { + ThrowObjectDisposedException(); + } + + return new ManagedConnection(_connections.Take(), this); + + static void ThrowObjectDisposedException() + { + throw new ObjectDisposedException(nameof(ConnectionPool)); + } + } + + /// <summary> + /// Return a database connection to the pool. + /// </summary> + /// <param name="connection">The database connection to return.</param> + public void Return(SQLiteDatabaseConnection connection) + { + if (_disposed) + { + connection.Dispose(); + return; + } + + _connections.Add(connection); + } + + /// <inheritdoc /> + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (var connection in _connections) + { + connection.Dispose(); + } + + _connections.Dispose(); + + _disposed = true; + } +} diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index 11e33278d4..e84ed8f918 100644 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -2,23 +2,22 @@ using System; using System.Collections.Generic; -using System.Threading; using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { public sealed class ManagedConnection : IDisposable { - private readonly SemaphoreSlim _writeLock; + private readonly ConnectionPool _pool; - private SQLiteDatabaseConnection? _db; + private SQLiteDatabaseConnection _db; private bool _disposed = false; - public ManagedConnection(SQLiteDatabaseConnection db, SemaphoreSlim writeLock) + public ManagedConnection(SQLiteDatabaseConnection db, ConnectionPool pool) { _db = db; - _writeLock = writeLock; + _pool = pool; } public IStatement PrepareStatement(string sql) @@ -73,9 +72,9 @@ namespace Emby.Server.Implementations.Data return; } - _writeLock.Release(); + _pool.Return(_db); - _db = null; // Don't dispose it + _db = null!; // Don't dispose it _disposed = true; } } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index bc703fe90d..ca8f605a02 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -25,6 +25,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -34,6 +35,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -49,8 +51,8 @@ namespace Emby.Server.Implementations.Data private const string SaveItemCommandText = @"replace into TypedBaseItems - (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) - values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; + (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) + values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; private readonly IServerConfigurationManager _config; private readonly IServerApplicationHost _appHost; @@ -110,6 +112,7 @@ namespace Emby.Server.Implementations.Data "PrimaryVersionId", "DateLastMediaAdded", "Album", + "LUFS", "CriticRating", "IsVirtualItem", "SeriesName", @@ -318,13 +321,15 @@ namespace Emby.Server.Implementations.Data /// <param name="logger">Instance of the <see cref="ILogger{SqliteItemRepository}"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <exception cref="ArgumentNullException">config is null.</exception> public SqliteItemRepository( IServerConfigurationManager config, IServerApplicationHost appHost, ILogger<SqliteItemRepository> logger, ILocalizationManager localization, - IImageProcessor imageProcessor) + IImageProcessor imageProcessor, + IConfiguration configuration) : base(logger) { _config = config; @@ -336,10 +341,13 @@ namespace Emby.Server.Implementations.Data _jsonOptions = JsonDefaults.Options; DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db"); + + CacheSize = configuration.GetSqliteCacheSize(); + ReadConnectionsCount = Environment.ProcessorCount * 2; } /// <inheritdoc /> - protected override int? CacheSize => 20000; + protected override int? CacheSize { get; } /// <inheritdoc /> protected override TempStoreMode TempStore => TempStoreMode.Memory; @@ -347,10 +355,10 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Opens the connection to the database. /// </summary> - /// <param name="userDataRepo">The user data repository.</param> - /// <param name="userManager">The user manager.</param> - public void Initialize(SqliteUserDataRepository userDataRepo, IUserManager userManager) + public override void Initialize() { + base.Initialize(); + const string CreateMediaStreamsTableCommand = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, DvVersionMajor INT NULL, DvVersionMinor INT NULL, DvProfile INT NULL, DvLevel INT NULL, RpuPresentFlag INT NULL, ElPresentFlag INT NULL, BlPresentFlag INT NULL, DvBlSignalCompatibilityId INT NULL, IsHearingImpaired BIT NULL, PRIMARY KEY (ItemId, StreamIndex))"; @@ -488,6 +496,7 @@ namespace Emby.Server.Implementations.Data AddColumn(db, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); AddColumn(db, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); AddColumn(db, "TypedBaseItems", "Album", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "LUFS", "Float", existingColumnNames); AddColumn(db, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); AddColumn(db, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); AddColumn(db, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); @@ -551,8 +560,6 @@ namespace Emby.Server.Implementations.Data connection.RunQueries(postQueries); } - - userDataRepo.Initialize(userManager, WriteLock, WriteConnection); } public void SaveImages(BaseItem item) @@ -586,7 +593,7 @@ namespace Emby.Server.Implementations.Data /// <exception cref="ArgumentNullException"> /// <paramref name="items"/> or <paramref name="cancellationToken"/> is <c>null</c>. /// </exception> - public void SaveItems(IEnumerable<BaseItem> items, CancellationToken cancellationToken) + public void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(items); @@ -594,9 +601,11 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); - var tuples = new List<(BaseItem, List<Guid>, BaseItem, string, List<string>)>(); - foreach (var item in items) + var itemsLen = items.Count; + var tuples = new ValueTuple<BaseItem, List<Guid>, BaseItem, string, List<string>>[itemsLen]; + for (int i = 0; i < itemsLen; i++) { + var item = items[i]; var ancestorIds = item.SupportsAncestors ? item.GetAncestorIds().Distinct().ToList() : null; @@ -606,7 +615,7 @@ namespace Emby.Server.Implementations.Data var userdataKey = item.GetUserDataKeys().FirstOrDefault(); var inheritedTags = item.GetInheritedTags(); - tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags)); + tuples[i] = (item, ancestorIds, topParent, userdataKey, inheritedTags); } using (var connection = GetConnection()) @@ -622,14 +631,8 @@ namespace Emby.Server.Implementations.Data private void SaveItemsInTransaction(IDatabaseConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples) { - var statements = PrepareAll(db, new string[] - { - SaveItemCommandText, - "delete from AncestorIds where ItemId=@ItemId" - }); - - using (var saveItemStatement = statements[0]) - using (var deleteAncestorsStatement = statements[1]) + using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText)) + using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId")) { var requiresReset = false; foreach (var tuple in tuples) @@ -911,6 +914,7 @@ namespace Emby.Server.Implementations.Data } saveItemStatement.TryBind("@Album", item.Album); + saveItemStatement.TryBind("@LUFS", item.LUFS); saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem); if (item is IHasSeries hasSeriesName) @@ -1195,7 +1199,7 @@ namespace Emby.Server.Implementations.Data Path = RestorePath(path.ToString()) }; - if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks) + if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks) && ticks >= DateTime.MinValue.Ticks && ticks <= DateTime.MaxValue.Ticks) { @@ -1284,15 +1288,13 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) { - using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) - { - statement.TryBind("@guid", id); + statement.TryBind("@guid", id); - foreach (var row in statement.ExecuteQuery()) - { - return GetItem(row, new InternalItemsQuery()); - } + foreach (var row in statement.ExecuteQuery()) + { + return GetItem(row, new InternalItemsQuery()); } } @@ -1307,7 +1309,8 @@ namespace Emby.Server.Implementations.Data { return false; } - else if (type == typeof(UserRootFolder)) + + if (type == typeof(UserRootFolder)) { return false; } @@ -1317,55 +1320,68 @@ namespace Emby.Server.Implementations.Data { return false; } - else if (type == typeof(MusicArtist)) + + if (type == typeof(MusicArtist)) { return false; } - else if (type == typeof(Person)) + + if (type == typeof(Person)) { return false; } - else if (type == typeof(MusicGenre)) + + if (type == typeof(MusicGenre)) { return false; } - else if (type == typeof(Genre)) + + if (type == typeof(Genre)) { return false; } - else if (type == typeof(Studio)) + + if (type == typeof(Studio)) { return false; } - else if (type == typeof(PlaylistsFolder)) + + if (type == typeof(PlaylistsFolder)) { return false; } - else if (type == typeof(PhotoAlbum)) + + if (type == typeof(PhotoAlbum)) { return false; } - else if (type == typeof(Year)) + + if (type == typeof(Year)) { return false; } - else if (type == typeof(Book)) + + if (type == typeof(Book)) { return false; } - else if (type == typeof(LiveTvProgram)) + + if (type == typeof(LiveTvProgram)) { return false; } - else if (type == typeof(AudioBook)) + + if (type == typeof(AudioBook)) { return false; } - else if (type == typeof(Audio)) + + if (type == typeof(Audio)) { return false; } - else if (type == typeof(MusicAlbum)) + + if (type == typeof(MusicAlbum)) { return false; } @@ -1749,6 +1765,11 @@ namespace Emby.Server.Implementations.Data item.Album = album; } + if (reader.TryGetSingle(index++, out var lUFS)) + { + item.LUFS = lUFS; + } + if (reader.TryGetSingle(index++, out var criticRating)) { item.CriticRating = criticRating; @@ -1956,22 +1977,19 @@ namespace Emby.Server.Implementations.Data { CheckDisposed(); + var chapters = new List<ChapterInfo>(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) { - var chapters = new List<ChapterInfo>(); + statement.TryBind("@ItemId", item.Id); - using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) + foreach (var row in statement.ExecuteQuery()) { - statement.TryBind("@ItemId", item.Id); - - foreach (var row in statement.ExecuteQuery()) - { - chapters.Add(GetChapter(row, item)); - } + chapters.Add(GetChapter(row, item)); } - - return chapters; } + + return chapters; } /// <inheritdoc /> @@ -1980,16 +1998,14 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) { - using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) - { - statement.TryBind("@ItemId", item.Id); - statement.TryBind("@ChapterIndex", index); + statement.TryBind("@ItemId", item.Id); + statement.TryBind("@ChapterIndex", index); - foreach (var row in statement.ExecuteQuery()) - { - return GetChapter(row, item); - } + foreach (var row in statement.ExecuteQuery()) + { + return GetChapter(row, item); } } @@ -2376,7 +2392,7 @@ namespace Emby.Server.Implementations.Data else { builder.Append( - @"(SELECT CASE WHEN InheritedParentalRatingValue=0 + @"(SELECT CASE WHEN COALESCE(InheritedParentalRatingValue, 0)=0 THEN 0 ELSE 10.0 / (1.0 + ABS(InheritedParentalRatingValue - @InheritedParentalRatingValue)) END)"); @@ -2390,6 +2406,7 @@ namespace Emby.Server.Implementations.Data // genres, tags, studios, person, year? builder.Append("+ (Select count(1) * 10 from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from ItemValues where ItemId=@SimilarItemId))"); + builder.Append("+ (Select count(1) * 10 from People where ItemId=Guid and Name in (select Name from People where ItemId=@SimilarItemId))"); if (item is MusicArtist) { @@ -2841,13 +2858,10 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction( db => { - var itemQueryStatement = PrepareStatement(db, itemQuery); - var totalRecordCountQueryStatement = PrepareStatement(db, totalRecordCountQuery); - if (!isReturningZeroItems) { using (new QueryTimeLogger(Logger, itemQuery, "GetItems.ItemQuery")) - using (var statement = itemQueryStatement) + using (var statement = PrepareStatement(db, itemQuery)) { if (EnableJoinUserData(query)) { @@ -2882,7 +2896,7 @@ namespace Emby.Server.Implementations.Data if (query.EnableTotalRecordCount) { using (new QueryTimeLogger(Logger, totalRecordCountQuery, "GetItems.TotalRecordCount")) - using (var statement = totalRecordCountQueryStatement) + using (var statement = PrepareStatement(db, totalRecordCountQuery)) { if (EnableJoinUserData(query)) { @@ -3202,7 +3216,8 @@ namespace Emby.Server.Implementations.Data return IsAlphaNumeric(value); } - private List<string> GetWhereClauses(InternalItemsQuery query, IStatement statement) +#nullable enable + private List<string> GetWhereClauses(InternalItemsQuery query, IStatement? statement) { if (query.IsResumable ?? false) { @@ -3677,7 +3692,6 @@ namespace Emby.Server.Implementations.Data if (statement is not null) { nameContains = FixUnicodeChars(nameContains); - statement.TryBind("@NameContains", "%" + GetCleanValue(nameContains) + "%"); } } @@ -3803,13 +3817,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.ArtistIds) { var paramName = "@ArtistIds" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3824,13 +3833,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.AlbumArtistIds) { var paramName = "@ArtistIds" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3845,13 +3849,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.ContributingArtistIds) { var paramName = "@ArtistIds" + index; - clauses.Add("((select CleanName from TypedBaseItems where guid=" + paramName + ") in (select CleanValue from ItemValues where ItemId=Guid and Type=0) AND (select CleanName from TypedBaseItems where guid=" + paramName + ") not in (select CleanValue from ItemValues where ItemId=Guid and Type=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3866,13 +3865,8 @@ namespace Emby.Server.Implementations.Data foreach (var albumId in query.AlbumIds) { var paramName = "@AlbumIds" + index; - clauses.Add("Album in (select Name from typedbaseitems where guid=" + paramName + ")"); - if (statement is not null) - { - statement.TryBind(paramName, albumId); - } - + statement?.TryBind(paramName, albumId); index++; } @@ -3887,13 +3881,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.ExcludeArtistIds) { var paramName = "@ExcludeArtistId" + index; - clauses.Add("(guid not in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3908,13 +3897,8 @@ namespace Emby.Server.Implementations.Data foreach (var genreId in query.GenreIds) { var paramName = "@GenreId" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=2))"); - if (statement is not null) - { - statement.TryBind(paramName, genreId); - } - + statement?.TryBind(paramName, genreId); index++; } @@ -3929,11 +3913,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in query.Genres) { clauses.Add("@Genre" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=2)"); - if (statement is not null) - { - statement.TryBind("@Genre" + index, GetCleanValue(item)); - } - + statement?.TryBind("@Genre" + index, GetCleanValue(item)); index++; } @@ -3948,11 +3928,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in tags) { clauses.Add("@Tag" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=4)"); - if (statement is not null) - { - statement.TryBind("@Tag" + index, GetCleanValue(item)); - } - + statement?.TryBind("@Tag" + index, GetCleanValue(item)); index++; } @@ -3967,11 +3943,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in excludeTags) { clauses.Add("@ExcludeTag" + index + " not in (select CleanValue from ItemValues where ItemId=Guid and Type=4)"); - if (statement is not null) - { - statement.TryBind("@ExcludeTag" + index, GetCleanValue(item)); - } - + statement?.TryBind("@ExcludeTag" + index, GetCleanValue(item)); index++; } @@ -3986,14 +3958,8 @@ namespace Emby.Server.Implementations.Data foreach (var studioId in query.StudioIds) { var paramName = "@StudioId" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=3))"); - - if (statement is not null) - { - statement.TryBind(paramName, studioId); - } - + statement?.TryBind(paramName, studioId); index++; } @@ -4008,11 +3974,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in query.OfficialRatings) { clauses.Add("OfficialRating=@OfficialRating" + index); - if (statement is not null) - { - statement.TryBind("@OfficialRating" + index, item); - } - + statement?.TryBind("@OfficialRating" + index, item); index++; } @@ -4020,35 +3982,97 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(clause); } - if (query.MinParentalRating.HasValue) + var ratingClauseBuilder = new StringBuilder("("); + if (query.HasParentalRating ?? false) { - whereClauses.Add("InheritedParentalRatingValue>=@MinParentalRating"); - if (statement is not null) + ratingClauseBuilder.Append("InheritedParentalRatingValue not null"); + if (query.MinParentalRating.HasValue) { - statement.TryBind("@MinParentalRating", query.MinParentalRating.Value); + ratingClauseBuilder.Append(" AND InheritedParentalRatingValue >= @MinParentalRating"); + statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); } - } - if (query.MaxParentalRating.HasValue) + if (query.MaxParentalRating.HasValue) + { + ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); + } + } + else if (query.BlockUnratedItems.Length > 0) { - whereClauses.Add("InheritedParentalRatingValue<=@MaxParentalRating"); + var paramName = "@UnratedType"; + var index = 0; + string blockedUnratedItems = string.Join(',', query.BlockUnratedItems.Select(_ => paramName + index++)); + ratingClauseBuilder.Append("(InheritedParentalRatingValue is null AND UnratedType not in (" + blockedUnratedItems + "))"); + if (statement is not null) { - statement.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); + for (var ind = 0; ind < query.BlockUnratedItems.Length; ind++) + { + statement.TryBind(paramName + ind, query.BlockUnratedItems[ind].ToString()); + } } - } - if (query.HasParentalRating.HasValue) - { - if (query.HasParentalRating.Value) + if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) { - whereClauses.Add("InheritedParentalRatingValue > 0"); + ratingClauseBuilder.Append(" OR ("); } - else + + if (query.MinParentalRating.HasValue) + { + ratingClauseBuilder.Append("InheritedParentalRatingValue >= @MinParentalRating"); + statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); + } + + if (query.MaxParentalRating.HasValue) + { + if (query.MinParentalRating.HasValue) + { + ratingClauseBuilder.Append(" AND "); + } + + ratingClauseBuilder.Append("InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); + } + + if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) + { + ratingClauseBuilder.Append(")"); + } + + if (!(query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue)) { - whereClauses.Add("InheritedParentalRatingValue = 0"); + ratingClauseBuilder.Append(" OR InheritedParentalRatingValue not null"); } } + else if (query.MinParentalRating.HasValue) + { + ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR (InheritedParentalRatingValue >= @MinParentalRating"); + statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); + + if (query.MaxParentalRating.HasValue) + { + ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); + } + + ratingClauseBuilder.Append(")"); + } + else if (query.MaxParentalRating.HasValue) + { + ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); + } + else if (!query.HasParentalRating ?? false) + { + ratingClauseBuilder.Append("InheritedParentalRatingValue is null"); + } + + var ratingClauseString = ratingClauseBuilder.ToString(); + if (!string.Equals(ratingClauseString, "(", StringComparison.OrdinalIgnoreCase)) + { + whereClauses.Add(ratingClauseString + ")"); + } if (query.HasOfficialRating.HasValue) { @@ -4089,37 +4113,25 @@ namespace Emby.Server.Implementations.Data if (!string.IsNullOrWhiteSpace(query.HasNoAudioTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Audio' and MediaStreams.Language=@HasNoAudioTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoAudioTrackWithLanguage", query.HasNoAudioTrackWithLanguage); - } + statement?.TryBind("@HasNoAudioTrackWithLanguage", query.HasNoAudioTrackWithLanguage); } if (!string.IsNullOrWhiteSpace(query.HasNoInternalSubtitleTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=0 and MediaStreams.Language=@HasNoInternalSubtitleTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoInternalSubtitleTrackWithLanguage", query.HasNoInternalSubtitleTrackWithLanguage); - } + statement?.TryBind("@HasNoInternalSubtitleTrackWithLanguage", query.HasNoInternalSubtitleTrackWithLanguage); } if (!string.IsNullOrWhiteSpace(query.HasNoExternalSubtitleTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=1 and MediaStreams.Language=@HasNoExternalSubtitleTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoExternalSubtitleTrackWithLanguage", query.HasNoExternalSubtitleTrackWithLanguage); - } + statement?.TryBind("@HasNoExternalSubtitleTrackWithLanguage", query.HasNoExternalSubtitleTrackWithLanguage); } if (!string.IsNullOrWhiteSpace(query.HasNoSubtitleTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.Language=@HasNoSubtitleTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoSubtitleTrackWithLanguage", query.HasNoSubtitleTrackWithLanguage); - } + statement?.TryBind("@HasNoSubtitleTrackWithLanguage", query.HasNoSubtitleTrackWithLanguage); } if (query.HasSubtitles.HasValue) @@ -4169,15 +4181,11 @@ namespace Emby.Server.Implementations.Data if (query.Years.Length == 1) { whereClauses.Add("ProductionYear=@Years"); - if (statement is not null) - { - statement.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture)); - } + statement?.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture)); } else if (query.Years.Length > 1) { var val = string.Join(',', query.Years); - whereClauses.Add("ProductionYear in (" + val + ")"); } @@ -4185,10 +4193,7 @@ namespace Emby.Server.Implementations.Data if (isVirtualItem.HasValue) { whereClauses.Add("IsVirtualItem=@IsVirtualItem"); - if (statement is not null) - { - statement.TryBind("@IsVirtualItem", isVirtualItem.Value); - } + statement?.TryBind("@IsVirtualItem", isVirtualItem.Value); } if (query.IsSpecialSeason.HasValue) @@ -4219,31 +4224,22 @@ namespace Emby.Server.Implementations.Data if (queryMediaTypes.Length == 1) { whereClauses.Add("MediaType=@MediaTypes"); - if (statement is not null) - { - statement.TryBind("@MediaTypes", queryMediaTypes[0]); - } + statement?.TryBind("@MediaTypes", queryMediaTypes[0]); } else if (queryMediaTypes.Length > 1) { var val = string.Join(',', queryMediaTypes.Select(i => "'" + i + "'")); - whereClauses.Add("MediaType in (" + val + ")"); } if (query.ItemIds.Length > 0) { var includeIds = new List<string>(); - var index = 0; foreach (var id in query.ItemIds) { includeIds.Add("Guid = @IncludeId" + index); - if (statement is not null) - { - statement.TryBind("@IncludeId" + index, id); - } - + statement?.TryBind("@IncludeId" + index, id); index++; } @@ -4253,16 +4249,11 @@ namespace Emby.Server.Implementations.Data if (query.ExcludeItemIds.Length > 0) { var excludeIds = new List<string>(); - var index = 0; foreach (var id in query.ExcludeItemIds) { excludeIds.Add("Guid <> @ExcludeId" + index); - if (statement is not null) - { - statement.TryBind("@ExcludeId" + index, id); - } - + statement?.TryBind("@ExcludeId" + index, id); index++; } @@ -4283,11 +4274,7 @@ namespace Emby.Server.Implementations.Data var paramName = "@ExcludeProviderId" + index; excludeIds.Add("(ProviderIds is null or ProviderIds not like " + paramName + ")"); - if (statement is not null) - { - statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); - } - + statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); index++; break; @@ -4312,7 +4299,7 @@ namespace Emby.Server.Implementations.Data } // TODO this seems to be an idea for a better schema where ProviderIds are their own table - // buut this is not implemented + // but this is not implemented // hasProviderIds.Add("(COALESCE((select value from ProviderIds where ItemId=Guid and Name = '" + pair.Key + "'), '') <> " + paramName + ")"); // TODO this is a really BAD way to do it since the pair: @@ -4326,11 +4313,7 @@ namespace Emby.Server.Implementations.Data hasProviderIds.Add("ProviderIds like " + paramName); // this replaces the placeholder with a value, here: %key=val% - if (statement is not null) - { - statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); - } - + statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); index++; break; @@ -4407,11 +4390,7 @@ namespace Emby.Server.Implementations.Data if (query.AncestorIds.Length == 1) { whereClauses.Add("Guid in (select itemId from AncestorIds where AncestorId=@AncestorId)"); - - if (statement is not null) - { - statement.TryBind("@AncestorId", query.AncestorIds[0]); - } + statement?.TryBind("@AncestorId", query.AncestorIds[0]); } if (query.AncestorIds.Length > 1) @@ -4424,39 +4403,13 @@ namespace Emby.Server.Implementations.Data { var inClause = "select guid from TypedBaseItems where PresentationUniqueKey=@AncestorWithPresentationUniqueKey"; whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorId in ({0}))", inClause)); - if (statement is not null) - { - statement.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey); - } + statement?.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey); } if (!string.IsNullOrWhiteSpace(query.SeriesPresentationUniqueKey)) { whereClauses.Add("SeriesPresentationUniqueKey=@SeriesPresentationUniqueKey"); - - if (statement is not null) - { - statement.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey); - } - } - - if (query.BlockUnratedItems.Length == 1) - { - whereClauses.Add("(InheritedParentalRatingValue > 0 or UnratedType <> @UnratedType)"); - if (statement is not null) - { - statement.TryBind("@UnratedType", query.BlockUnratedItems[0].ToString()); - } - } - - if (query.BlockUnratedItems.Length > 1) - { - var inClause = string.Join(',', query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'")); - whereClauses.Add( - string.Format( - CultureInfo.InvariantCulture, - "(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", - inClause)); + statement?.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey); } if (query.ExcludeInheritedTags.Length > 0) @@ -4477,6 +4430,24 @@ namespace Emby.Server.Implementations.Data } } + if (query.IncludeInheritedTags.Length > 0) + { + var paramName = "@IncludeInheritedTags"; + if (statement is null) + { + int index = 0; + string includedTags = string.Join(',', query.IncludeInheritedTags.Select(_ => paramName + index++)); + whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)"); + } + else + { + for (int index = 0; index < query.IncludeInheritedTags.Length; index++) + { + statement.TryBind(paramName + index, GetCleanValue(query.IncludeInheritedTags[index])); + } + } + } + if (query.SeriesStatuses.Length > 0) { var statuses = new List<string>(); @@ -4587,6 +4558,7 @@ namespace Emby.Server.Implementations.Data return whereClauses; } +#nullable disable /// <summary> /// Formats a where clause for the specified provider. @@ -4793,22 +4765,20 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type commandText.Append(" LIMIT ").Append(query.Limit); } + var list = new List<string>(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, commandText.ToString())) { - var list = new List<string>(); - using (var statement = PrepareStatement(connection, commandText.ToString())) - { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); + // Run this again to bind the params + GetPeopleWhereClauses(query, statement); - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row.GetString(0)); - } + foreach (var row in statement.ExecuteQuery()) + { + list.Add(row.GetString(0)); } - - return list; } + + return list; } public List<PersonInfo> GetPeople(InternalPeopleQuery query) @@ -4833,23 +4803,20 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type commandText += " LIMIT " + query.Limit; } + var list = new List<PersonInfo>(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - var list = new List<PersonInfo>(); + // Run this again to bind the params + GetPeopleWhereClauses(query, statement); - using (var statement = PrepareStatement(connection, commandText)) + foreach (var row in statement.ExecuteQuery()) { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetPerson(row)); - } + list.Add(GetPerson(row)); } - - return list; } + + return list; } private List<string> GetPeopleWhereClauses(InternalPeopleQuery query, IStatement statement) @@ -5440,6 +5407,9 @@ AND Type = @InternalPersonType)"); list.AddRange(inheritedTags.Select(i => (6, i))); + // Remove all invalid values. + list.RemoveAll(i => string.IsNullOrEmpty(i.Item2)); + return list; } @@ -5577,7 +5547,7 @@ AND Type = @InternalPersonType)"); statement.TryBind("@Name" + index, person.Name); statement.TryBind("@Role" + index, person.Role); - statement.TryBind("@PersonType" + index, person.Type); + statement.TryBind("@PersonType" + index, person.Type.ToString()); statement.TryBind("@SortOrder" + index, person.SortOrder); statement.TryBind("@ListOrder" + index, listIndex); @@ -5606,9 +5576,10 @@ AND Type = @InternalPersonType)"); item.Role = role; } - if (reader.TryGetString(3, out var type)) + if (reader.TryGetString(3, out var type) + && Enum.TryParse(type, true, out PersonKind personKind)) { - item.Type = type; + item.Type = personKind; } if (reader.TryGetInt32(4, out var sortOrder)) diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index 5f2c3c9dcc..a1e217ad14 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using System.IO; using System.Threading; using Jellyfin.Data.Entities; -using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; @@ -18,33 +18,32 @@ namespace Emby.Server.Implementations.Data { public class SqliteUserDataRepository : BaseSqliteRepository, IUserDataRepository { + private readonly IUserManager _userManager; + public SqliteUserDataRepository( ILogger<SqliteUserDataRepository> logger, - IApplicationPaths appPaths) + IServerConfigurationManager config, + IUserManager userManager) : base(logger) { - DbFilePath = Path.Combine(appPaths.DataPath, "library.db"); + _userManager = userManager; + + DbFilePath = Path.Combine(config.ApplicationPaths.DataPath, "library.db"); } /// <summary> /// Opens the connection to the database. /// </summary> - /// <param name="userManager">The user manager.</param> - /// <param name="dbLock">The lock to use for database IO.</param> - /// <param name="dbConnection">The connection to use for database IO.</param> - public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection) + public override void Initialize() { - WriteLock.Dispose(); - WriteLock = dbLock; - WriteConnection?.Dispose(); - WriteConnection = dbConnection; + base.Initialize(); using (var connection = GetConnection()) { var userDatasTableExists = TableExists(connection, "UserDatas"); var userDataTableExists = TableExists(connection, "userdata"); - var users = userDatasTableExists ? null : userManager.Users; + var users = userDatasTableExists ? null : _userManager.Users; connection.RunInTransaction( db => @@ -371,20 +370,5 @@ namespace Emby.Server.Implementations.Data return userData; } - -#pragma warning disable CA2215 - /// <inheritdoc/> - /// <remarks> - /// There is nothing to dispose here since <see cref="BaseSqliteRepository.WriteLock"/> and - /// <see cref="BaseSqliteRepository.WriteConnection"/> are managed by <see cref="SqliteItemRepository"/>. - /// See <see cref="Initialize(IUserManager, SemaphoreSlim, SQLiteDatabaseConnection)"/>. - /// </remarks> - protected override void Dispose(bool dispose) - { - // The write lock and connection for the item repository are shared with the user data repository - // since they point to the same database. The item repo has responsibility for disposing these two objects, - // so the user data repo should not attempt to dispose them as well - } -#pragma warning restore CA2215 } } diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 5103b1fbfb..7a6ed2cb80 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using Jellyfin.Api.Helpers; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -83,22 +82,23 @@ namespace Emby.Server.Implementations.Dto /// <inheritdoc /> public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null) { - var returnItems = new BaseItemDto[items.Count]; - var programTuples = new List<(BaseItem, BaseItemDto)>(); - var channelTuples = new List<(BaseItemDto, LiveTvChannel)>(); + var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); + var returnItems = new BaseItemDto[accessibleItems.Count]; + List<(BaseItem, BaseItemDto)> programTuples = null; + List<(BaseItemDto, LiveTvChannel)> channelTuples = null; - for (int index = 0; index < items.Count; index++) + for (int index = 0; index < accessibleItems.Count; index++) { - var item = items[index]; + var item = accessibleItems[index]; var dto = GetBaseItemDtoInternal(item, options, user, owner); if (item is LiveTvChannel tvChannel) { - channelTuples.Add((dto, tvChannel)); + (channelTuples ??= new()).Add((dto, tvChannel)); } else if (item is LiveTvProgram) { - programTuples.Add((item, dto)); + (programTuples ??= new()).Add((item, dto)); } if (item is IItemByName byName) @@ -121,12 +121,12 @@ namespace Emby.Server.Implementations.Dto returnItems[index] = dto; } - if (programTuples.Count > 0) + if (programTuples is not null) { LivetvManager.AddInfoToProgramDto(programTuples, options.Fields, user).GetAwaiter().GetResult(); } - if (channelTuples.Count > 0) + if (channelTuples is not null) { LivetvManager.AddChannelInfo(channelTuples, options, user); } @@ -522,32 +522,32 @@ namespace Emby.Server.Implementations.Dto var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue) .ThenBy(i => { - if (i.IsType(PersonType.Actor)) + if (i.IsType(PersonKind.Actor)) { return 0; } - if (i.IsType(PersonType.GuestStar)) + if (i.IsType(PersonKind.GuestStar)) { return 1; } - if (i.IsType(PersonType.Director)) + if (i.IsType(PersonKind.Director)) { return 2; } - if (i.IsType(PersonType.Writer)) + if (i.IsType(PersonKind.Writer)) { return 3; } - if (i.IsType(PersonType.Producer)) + if (i.IsType(PersonKind.Producer)) { return 4; } - if (i.IsType(PersonType.Composer)) + if (i.IsType(PersonKind.Composer)) { return 4; } @@ -571,9 +571,7 @@ namespace Emby.Server.Implementations.Dto return null; } }).Where(i => i is not null) - .Where(i => user is null ? - true : - i.IsVisible(user)) + .Where(i => user is null || i.IsVisible(user)) .DistinctBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); @@ -908,6 +906,7 @@ namespace Emby.Server.Implementations.Dto // Add audio info if (item is Audio audio) { + dto.LUFS = audio.LUFS; dto.Album = audio.Album; if (audio.ExtraType.HasValue) { diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 1b5c879beb..b8655c7600 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -22,17 +22,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="DiscUtils.Udf" Version="0.16.13" /> - <PackageReference Include="Jellyfin.XmlTv" Version="10.8.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.2" /> - <PackageReference Include="Mono.Nat" Version="3.0.4" /> - <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.0" /> - <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> - <PackageReference Include="DotNet.Glob" Version="3.1.3" /> + <PackageReference Include="DiscUtils.Udf" /> + <PackageReference Include="Jellyfin.XmlTv" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> + <PackageReference Include="Mono.Nat" /> + <PackageReference Include="prometheus-net.DotNetRuntime" /> + <PackageReference Include="SQLitePCL.pretty.netstandard" /> + <PackageReference Include="DotNet.Glob" /> </ItemGroup> <ItemGroup> @@ -53,13 +53,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 05d0a9b794..be36bbd2c1 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Events; using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -26,12 +27,8 @@ namespace Emby.Server.Implementations.EntryPoints { public class LibraryChangedNotifier : IServerEntryPoint { - /// <summary> - /// The library update duration. - /// </summary> - private const int LibraryUpdateDuration = 30000; - private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; private readonly IProviderManager _providerManager; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; @@ -51,12 +48,14 @@ namespace Emby.Server.Implementations.EntryPoints public LibraryChangedNotifier( ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, ISessionManager sessionManager, IUserManager userManager, ILogger<LibraryChangedNotifier> logger, IProviderManager providerManager) { _libraryManager = libraryManager; + _configurationManager = configurationManager; _sessionManager = sessionManager; _userManager = userManager; _logger = logger; @@ -196,12 +195,12 @@ namespace Emby.Server.Implementations.EntryPoints LibraryUpdateTimer = new Timer( LibraryUpdateTimerCallback, null, - LibraryUpdateDuration, - Timeout.Infinite); + TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), + Timeout.InfiniteTimeSpan); } else { - LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer.Change(TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } if (e.Item.GetParent() is Folder parent) @@ -229,11 +228,11 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer is null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } else { - LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer.Change(TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } _itemsUpdated.Add(e.Item); @@ -256,11 +255,11 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer is null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } else { - LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer.Change(TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } if (e.Parent is Folder parent) @@ -276,25 +275,31 @@ namespace Emby.Server.Implementations.EntryPoints /// Libraries the update timer callback. /// </summary> /// <param name="state">The state.</param> - private void LibraryUpdateTimerCallback(object state) + private async void LibraryUpdateTimerCallback(object state) { + List<Folder> foldersAddedTo; + List<Folder> foldersRemovedFrom; + List<BaseItem> itemsUpdated; + List<BaseItem> itemsAdded; + List<BaseItem> itemsRemoved; lock (_libraryChangedSyncLock) { // Remove dupes in case some were saved multiple times - var foldersAddedTo = _foldersAddedTo + foldersAddedTo = _foldersAddedTo .DistinctBy(x => x.Id) .ToList(); - var foldersRemovedFrom = _foldersRemovedFrom + foldersRemovedFrom = _foldersRemovedFrom .DistinctBy(x => x.Id) .ToList(); - var itemsUpdated = _itemsUpdated + itemsUpdated = _itemsUpdated .Where(i => !_itemsAdded.Contains(i)) .DistinctBy(x => x.Id) .ToList(); - SendChangeNotifications(_itemsAdded.ToList(), itemsUpdated, _itemsRemoved.ToList(), foldersAddedTo, foldersRemovedFrom, CancellationToken.None).GetAwaiter().GetResult(); + itemsAdded = _itemsAdded.ToList(); + itemsRemoved = _itemsRemoved.ToList(); if (LibraryUpdateTimer is not null) { @@ -308,6 +313,8 @@ namespace Emby.Server.Implementations.EntryPoints _foldersAddedTo.Clear(); _foldersRemovedFrom.Clear(); } + + await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false); } /// <summary> diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index e724618b3a..d32759017d 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -87,29 +87,30 @@ namespace Emby.Server.Implementations.EntryPoints } } - private void UpdateTimerCallback(object? state) + private async void UpdateTimerCallback(object? state) { + List<KeyValuePair<Guid, List<BaseItem>>> changes; lock (_syncLock) { // Remove dupes in case some were saved multiple times - var changes = _changedItems.ToList(); + changes = _changedItems.ToList(); _changedItems.Clear(); - SendNotifications(changes, CancellationToken.None).GetAwaiter().GetResult(); - if (_updateTimer is not null) { _updateTimer.Dispose(); _updateTimer = null; } } + + await SendNotifications(changes, CancellationToken.None).ConfigureAwait(false); } private async Task SendNotifications(List<KeyValuePair<Guid, List<BaseItem>>> changes, CancellationToken cancellationToken) { - foreach (var pair in changes) + foreach ((var key, var value) in changes) { - await SendNotifications(pair.Key, pair.Value, cancellationToken).ConfigureAwait(false); + await SendNotifications(key, value, cancellationToken).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index b1a99853ad..af79c18c4e 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -9,7 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; @@ -88,6 +88,18 @@ namespace Emby.Server.Implementations.HttpServer /// <summary> /// Sends a message asynchronously. /// </summary> + /// <param name="message">The message.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendAsync(WebSocketMessage message, CancellationToken cancellationToken) + { + var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); + return _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken); + } + + /// <summary> + /// Sends a message asynchronously. + /// </summary> /// <typeparam name="T">The type of the message.</typeparam> /// <param name="message">The message.</param> /// <param name="cancellationToken">The cancellation token.</param> @@ -224,7 +236,7 @@ namespace Emby.Server.Implementations.HttpServer { LastKeepAliveDate = DateTime.UtcNow; return SendAsync( - new WebSocketMessage<string> + new OutboundWebSocketMessage { MessageId = Guid.NewGuid(), MessageType = SessionMessageType.KeepAlive diff --git a/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs b/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs deleted file mode 100644 index 545d73e05f..0000000000 --- a/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs +++ /dev/null @@ -1,13 +0,0 @@ -#pragma warning disable CS1591 - -namespace Emby.Server.Implementations.IO -{ - public class ExtendedFileSystemInfo - { - public bool IsHidden { get; set; } - - public bool IsReadOnly { get; set; } - - public bool Exists { get; set; } - } -} diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 7b8c79e8a9..60ab668cde 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -267,25 +267,6 @@ namespace Emby.Server.Implementations.IO return result; } - private static ExtendedFileSystemInfo GetExtendedFileSystemInfo(string path) - { - var result = new ExtendedFileSystemInfo(); - - var info = new FileInfo(path); - - if (info.Exists) - { - result.Exists = true; - - var attributes = info.Attributes; - - result.IsHidden = (attributes & FileAttributes.Hidden) == FileAttributes.Hidden; - result.IsReadOnly = (attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly; - } - - return result; - } - /// <summary> /// Takes a filename and removes invalid characters. /// </summary> @@ -405,19 +386,18 @@ namespace Emby.Server.Implementations.IO return; } - var info = GetExtendedFileSystemInfo(path); + var info = new FileInfo(path); - if (info.Exists && info.IsHidden != isHidden) + if (info.Exists && + ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) != isHidden) { if (isHidden) { - File.SetAttributes(path, File.GetAttributes(path) | FileAttributes.Hidden); + File.SetAttributes(path, info.Attributes | FileAttributes.Hidden); } else { - var attributes = File.GetAttributes(path); - attributes = RemoveAttribute(attributes, FileAttributes.Hidden); - File.SetAttributes(path, attributes); + File.SetAttributes(path, info.Attributes & ~FileAttributes.Hidden); } } } @@ -430,19 +410,20 @@ namespace Emby.Server.Implementations.IO return; } - var info = GetExtendedFileSystemInfo(path); + var info = new FileInfo(path); if (!info.Exists) { return; } - if (info.IsReadOnly == readOnly && info.IsHidden == isHidden) + if (((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) == readOnly + && ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) == isHidden) { return; } - var attributes = File.GetAttributes(path); + var attributes = info.Attributes; if (readOnly) { @@ -450,7 +431,7 @@ namespace Emby.Server.Implementations.IO } else { - attributes = RemoveAttribute(attributes, FileAttributes.ReadOnly); + attributes &= ~FileAttributes.ReadOnly; } if (isHidden) @@ -459,17 +440,12 @@ namespace Emby.Server.Implementations.IO } else { - attributes = RemoveAttribute(attributes, FileAttributes.Hidden); + attributes &= ~FileAttributes.Hidden; } File.SetAttributes(path, attributes); } - private static FileAttributes RemoveAttribute(FileAttributes attributes, FileAttributes attributesToRemove) - { - return attributes & ~attributesToRemove; - } - /// <summary> /// Swaps the files. /// </summary> diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs index 6fc7f1ac3a..84c21931c3 100644 --- a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index 4376bd356c..90f7568a90 100644 --- a/Emby.Server.Implementations/Images/FolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using MediaBrowser.Common.Configuration; diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index 968bf5fa33..c9b41f8193 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index a3c66dc798..ea45bf0ba0 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -113,6 +113,7 @@ namespace Emby.Server.Implementations.Library /// <param name="imageProcessor">The image processor.</param> /// <param name="memoryCache">The memory cache.</param> /// <param name="namingOptions">The naming options.</param> + /// <param name="directoryService">The directory service.</param> public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -128,7 +129,8 @@ namespace Emby.Server.Implementations.Library IItemRepository itemRepository, IImageProcessor imageProcessor, IMemoryCache memoryCache, - NamingOptions namingOptions) + NamingOptions namingOptions, + IDirectoryService directoryService) { _appHost = appHost; _logger = loggerFactory.CreateLogger<LibraryManager>(); @@ -146,7 +148,7 @@ namespace Emby.Server.Implementations.Library _memoryCache = memoryCache; _namingOptions = namingOptions; - _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions); + _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -356,8 +358,8 @@ namespace Emby.Server.Implementations.Library } var children = item.IsFolder - ? ((Folder)item).GetRecursiveChildren(false).ToList() - : new List<BaseItem>(); + ? ((Folder)item).GetRecursiveChildren(false) + : Enumerable.Empty<BaseItem>(); foreach (var metadataPath in GetMetadataPaths(item, children)) { @@ -537,7 +539,7 @@ namespace Emby.Server.Implementations.Library collectionType = GetContentTypeOverride(fullPath, true); } - var args = new ItemResolveArgs(_configurationManager.ApplicationPaths, directoryService) + var args = new ItemResolveArgs(_configurationManager.ApplicationPaths, this) { Parent = parent, FileInfo = fileInfo, @@ -1253,7 +1255,7 @@ namespace Emby.Server.Implementations.Library var parent = GetItemById(query.ParentId); if (parent is not null) { - SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent }); + SetTopParentIdsOrAncestors(query, new[] { parent }); } } @@ -1262,7 +1264,14 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User, allowExternalContent); } - return _itemRepository.GetItemList(query); + var itemList = _itemRepository.GetItemList(query); + var user = query.User; + if (user is not null) + { + return itemList.Where(i => i.IsVisible(user)).ToList(); + } + + return itemList; } public List<BaseItem> GetItemList(InternalItemsQuery query) @@ -1277,7 +1286,7 @@ namespace Emby.Server.Implementations.Library var parent = GetItemById(query.ParentId); if (parent is not null) { - SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent }); + SetTopParentIdsOrAncestors(query, new[] { parent }); } } @@ -1435,7 +1444,7 @@ namespace Emby.Server.Implementations.Library var parent = GetItemById(query.ParentId); if (parent is not null) { - SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent }); + SetTopParentIdsOrAncestors(query, new[] { parent }); } } @@ -1455,7 +1464,7 @@ namespace Emby.Server.Implementations.Library _itemRepository.GetItemList(query)); } - private void SetTopParentIdsOrAncestors(InternalItemsQuery query, List<BaseItem> parents) + private void SetTopParentIdsOrAncestors(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents) { if (parents.All(i => i is ICollectionFolder || i is UserView)) { @@ -1501,6 +1510,12 @@ namespace Emby.Server.Implementations.Library }); query.TopParentIds = userViews.SelectMany(i => GetTopParentIdsForQuery(i, user)).ToArray(); + + // Prevent searching in all libraries due to empty filter + if (query.TopParentIds.Length == 0) + { + query.TopParentIds = new[] { Guid.NewGuid() }; + } } } @@ -1602,7 +1617,7 @@ namespace Emby.Server.Implementations.Library { _logger.LogError(ex, "Error getting intros"); - return new List<IntroInfo>(); + return Enumerable.Empty<IntroInfo>(); } } @@ -1877,7 +1892,7 @@ namespace Emby.Server.Implementations.Library catch (Exception ex) { _logger.LogError(ex, "Cannot get image dimensions for {ImagePath}", image.Path); - size = new ImageDimensions(0, 0); + size = default; image.Width = 0; image.Height = 0; } @@ -2741,9 +2756,7 @@ namespace Emby.Server.Implementations.Library } }) .Where(i => i is not null) - .Where(i => query.User is null ? - true : - i.IsVisible(query.User)) + .Where(i => query.User is null || i.IsVisible(query.User)) .ToList(); } @@ -2876,7 +2889,7 @@ namespace Emby.Server.Implementations.Library private async Task SavePeopleMetadataAsync(IEnumerable<PersonInfo> people, CancellationToken cancellationToken) { - var personsToSave = new List<BaseItem>(); + List<BaseItem> personsToSave = null; foreach (var person in people) { @@ -2918,12 +2931,12 @@ namespace Emby.Server.Implementations.Library if (saveEntity) { - personsToSave.Add(personEntity); + (personsToSave ??= new()).Add(personEntity); await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); } } - if (personsToSave.Count > 0) + if (personsToSave is not null) { CreateItems(personsToSave, null, CancellationToken.None); } @@ -3085,22 +3098,19 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(path)); } - var removeList = new List<NameValuePair>(); + List<NameValuePair> removeList = null; foreach (var contentType in _configurationManager.Configuration.ContentTypes) { - if (string.IsNullOrWhiteSpace(contentType.Name)) - { - removeList.Add(contentType); - } - else if (_fileSystem.AreEqual(path, contentType.Name) + if (string.IsNullOrWhiteSpace(contentType.Name) + || _fileSystem.AreEqual(path, contentType.Name) || _fileSystem.ContainsSubPath(path, contentType.Name)) { - removeList.Add(contentType); + (removeList ??= new()).Add(contentType); } } - if (removeList.Count > 0) + if (removeList is not null) { _configurationManager.Configuration.ContentTypes = _configurationManager.Configuration.ContentTypes .Except(removeList) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index eadfa5dfe9..c9a26a30f5 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -154,8 +154,8 @@ namespace Emby.Server.Implementations.Library // If file is strm or main media stream is missing, force a metadata refresh with remote probing if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder && (item.Path.EndsWith(".strm", StringComparison.OrdinalIgnoreCase) - || (item.MediaType == MediaType.Video && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Video)) - || (item.MediaType == MediaType.Audio && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Audio)))) + || (item.MediaType == MediaType.Video && mediaSources[0].MediaStreams.All(i => i.Type != MediaStreamType.Video)) + || (item.MediaType == MediaType.Audio && mediaSources[0].MediaStreams.All(i => i.Type != MediaStreamType.Audio)))) { await item.RefreshMetadata( new MetadataRefreshOptions(_directoryService) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 64e7d54466..c4b6b37561 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.IO; using MediaBrowser.Common.Providers; namespace Emby.Server.Implementations.Library @@ -86,24 +87,8 @@ namespace Emby.Server.Implementations.Library return false; } - char oldDirectorySeparatorChar; - char newDirectorySeparatorChar; - // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162 - // The reasoning behind this is that a forward slash likely means it's a Linux path and - // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much). - if (newSubPath.Contains('/', StringComparison.Ordinal)) - { - oldDirectorySeparatorChar = '\\'; - newDirectorySeparatorChar = '/'; - } - else - { - oldDirectorySeparatorChar = '/'; - newDirectorySeparatorChar = '\\'; - } - - path = path.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); - subPath = subPath.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); + subPath = subPath.NormalizePath(out var newDirectorySeparatorChar); + path = path.NormalizePath(newDirectorySeparatorChar); // We have to ensure that the sub path ends with a directory separator otherwise we'll get weird results // when the sub path matches a similar but in-complete subpath @@ -127,5 +112,82 @@ namespace Emby.Server.Implementations.Library return true; } + + /// <summary> + /// Retrieves the full resolved path and normalizes path separators to the <see cref="Path.DirectorySeparatorChar"/>. + /// </summary> + /// <param name="path">The path to canonicalize.</param> + /// <returns>The fully expanded, normalized path.</returns> + public static string Canonicalize(this string path) + { + return Path.GetFullPath(path).NormalizePath(); + } + + /// <summary> + /// Normalizes the path's directory separator character to the currently defined <see cref="Path.DirectorySeparatorChar"/>. + /// </summary> + /// <param name="path">The path to normalize.</param> + /// <returns>The normalized path string or <see langword="null"/> if the input path is null or empty.</returns> + [return: NotNullIfNotNull(nameof(path))] + public static string? NormalizePath(this string? path) + { + return path.NormalizePath(Path.DirectorySeparatorChar); + } + + /// <summary> + /// Normalizes the path's directory separator character. + /// </summary> + /// <param name="path">The path to normalize.</param> + /// <param name="separator">The separator character the path now uses or <see langword="null"/>.</param> + /// <returns>The normalized path string or <see langword="null"/> if the input path is null or empty.</returns> + [return: NotNullIfNotNull(nameof(path))] + public static string? NormalizePath(this string? path, out char separator) + { + if (string.IsNullOrEmpty(path)) + { + separator = default; + return path; + } + + var newSeparator = '\\'; + + // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162 + // The reasoning behind this is that a forward slash likely means it's a Linux path and + // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much). + if (path.Contains('/', StringComparison.Ordinal)) + { + newSeparator = '/'; + } + + separator = newSeparator; + + return path.NormalizePath(newSeparator); + } + + /// <summary> + /// Normalizes the path's directory separator character to the specified character. + /// </summary> + /// <param name="path">The path to normalize.</param> + /// <param name="newSeparator">The replacement directory separator character. Must be a valid directory separator.</param> + /// <returns>The normalized path.</returns> + /// <exception cref="ArgumentException">Thrown if the new separator character is not a directory separator.</exception> + [return: NotNullIfNotNull(nameof(path))] + public static string? NormalizePath(this string? path, char newSeparator) + { + const char Bs = '\\'; + const char Fs = '/'; + + if (!(newSeparator == Bs || newSeparator == Fs)) + { + throw new ArgumentException("The character must be a directory separator."); + } + + if (string.IsNullOrEmpty(path)) + { + return path; + } + + return newSeparator == Bs ? path.Replace(Fs, newSeparator) : path.Replace(Bs, newSeparator); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index 06621700aa..a74f824752 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -158,7 +158,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio private MultiItemResolverResult ResolveMultipleAudio(Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, bool parseName) { var files = new List<FileSystemMetadata>(); - var items = new List<BaseItem>(); var leftOver = new List<FileSystemMetadata>(); // Loop through each child file/folder and see if we find a video @@ -180,7 +179,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var result = new MultiItemResolverResult { ExtraFiles = leftOver, - Items = items + Items = new List<BaseItem>() }; var isInMixedFolder = resolverResult.Count > 1 || (parent is not null && parent.IsTopParent); @@ -193,7 +192,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio continue; } - if (resolvedItem.Files.Count == 0) + // Until multi-part books are handled letting files stack hides them from browsing in the client + if (resolvedItem.Files.Count == 0 || resolvedItem.Extras.Count > 0 || resolvedItem.AlternateVersions.Count > 0) { continue; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index a922e36855..bbc70701cb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -25,16 +25,19 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio { private readonly ILogger<MusicAlbumResolver> _logger; private readonly NamingOptions _namingOptions; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="MusicAlbumResolver"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> - public MusicAlbumResolver(ILogger<MusicAlbumResolver> logger, NamingOptions namingOptions) + /// <param name="directoryService">The directory service.</param> + public MusicAlbumResolver(ILogger<MusicAlbumResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) { _logger = logger; _namingOptions = namingOptions; + _directoryService = directoryService; } /// <summary> @@ -109,7 +112,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } // If args contains music it's a music album - if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService)) + if (ContainsMusic(args.FileSystemChildren, true, _directoryService)) { return true; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index 2538c2b5b4..c858dc53d9 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Emby.Naming.Common; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -18,19 +19,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio public class MusicArtistResolver : ItemResolver<MusicArtist> { private readonly ILogger<MusicAlbumResolver> _logger; - private NamingOptions _namingOptions; + private readonly NamingOptions _namingOptions; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="MusicArtistResolver"/> class. /// </summary> /// <param name="logger">Instance of the <see cref="MusicAlbumResolver"/> interface.</param> /// <param name="namingOptions">The <see cref="NamingOptions"/>.</param> + /// <param name="directoryService">The directory service.</param> public MusicArtistResolver( ILogger<MusicAlbumResolver> logger, - NamingOptions namingOptions) + NamingOptions namingOptions, + IDirectoryService directoryService) { _logger = logger; _namingOptions = namingOptions; + _directoryService = directoryService; } /// <summary> @@ -78,9 +83,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return null; } - var directoryService = args.DirectoryService; - - var albumResolver = new MusicAlbumResolver(_logger, _namingOptions); + var albumResolver = new MusicAlbumResolver(_logger, _namingOptions, _directoryService); var directories = args.FileSystemChildren.Where(i => i.IsDirectory); @@ -97,7 +100,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } // If we contain a music album assume we are an artist folder - if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, directoryService)) + if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, _directoryService)) { // Stop once we see a music album state.Stop(); diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index e8615e7db7..381796d0e3 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -25,14 +25,17 @@ namespace Emby.Server.Implementations.Library.Resolvers { private readonly ILogger _logger; - protected BaseVideoResolver(ILogger logger, NamingOptions namingOptions) + protected BaseVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) { _logger = logger; NamingOptions = namingOptions; + DirectoryService = directoryService; } protected NamingOptions NamingOptions { get; } + protected IDirectoryService DirectoryService { get; } + /// <summary> /// Resolves the specified args. /// </summary> @@ -65,13 +68,26 @@ namespace Emby.Server.Implementations.Library.Resolvers var filename = child.Name; if (child.IsDirectory) { - if (IsDvdDirectory(child.FullName, filename, args.DirectoryService)) + if (IsDvdDirectory(child.FullName, filename, DirectoryService)) { - videoType = VideoType.Dvd; + var videoTmp = new TVideoType + { + Path = args.Path, + VideoType = VideoType.Dvd + }; + Set3DFormat(videoTmp); + return videoTmp; } - else if (IsBluRayDirectory(filename)) + + if (IsBluRayDirectory(filename)) { - videoType = VideoType.BluRay; + var videoTmp = new TVideoType + { + Path = args.Path, + VideoType = VideoType.BluRay + }; + Set3DFormat(videoTmp); + return videoTmp; } } else if (IsDvdFile(filename)) diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index 30c52e19d3..b4791b9456 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -4,6 +4,8 @@ using System.IO; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -14,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <summary> /// Resolves a Path into a Video or Video subclass. /// </summary> - internal class ExtraResolver + internal class ExtraResolver : BaseVideoResolver<Video> { private readonly NamingOptions _namingOptions; private readonly IItemResolver[] _trailerResolvers; @@ -25,11 +27,18 @@ namespace Emby.Server.Implementations.Library.Resolvers /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">An instance of <see cref="NamingOptions"/>.</param> - public ExtraResolver(ILogger<ExtraResolver> logger, NamingOptions namingOptions) + /// <param name="directoryService">The directory service.</param> + public ExtraResolver(ILogger<ExtraResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) + : base(logger, namingOptions, directoryService) { _namingOptions = namingOptions; - _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions) }; - _videoResolvers = new IItemResolver[] { new GenericVideoResolver<Video>(logger, namingOptions) }; + _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) }; + _videoResolvers = new IItemResolver[] { this }; + } + + protected override Video Resolve(ItemResolveArgs args) + { + return ResolveVideo<Video>(args, true); } /// <summary> diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index 5e33b402d5..ba320266a4 100644 --- a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs @@ -2,6 +2,7 @@ using Emby.Naming.Common; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Resolvers @@ -18,8 +19,9 @@ namespace Emby.Server.Implementations.Library.Resolvers /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> - public GenericVideoResolver(ILogger logger, NamingOptions namingOptions) - : base(logger, namingOptions) + /// <param name="directoryService">The directory service.</param> + public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) + : base(logger, namingOptions, directoryService) { } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 1522cd3aef..ea980b9929 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -43,8 +43,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <param name="imageProcessor">The image processor.</param> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> - public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions) - : base(logger, namingOptions) + /// <param name="directoryService">The directory service.</param> + public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) + : base(logger, namingOptions, directoryService) { _imageProcessor = imageProcessor; } @@ -97,12 +98,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) { - movie = FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + movie = FindMovie<MusicVideo>(args, args.Path, args.Parent, files, DirectoryService, collectionType, false); } if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase)) { - movie = FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + movie = FindMovie<Video>(args, args.Path, args.Parent, files, DirectoryService, collectionType, false); } if (string.IsNullOrEmpty(collectionType)) @@ -118,12 +119,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return null; } - movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); + movie = FindMovie<Movie>(args, args.Path, args.Parent, files, DirectoryService, collectionType, true); } if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) { - movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); + movie = FindMovie<Movie>(args, args.Path, args.Parent, files, DirectoryService, collectionType, true); } // ignore extras @@ -313,13 +314,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return result; } - private static bool IsIgnored(string filename) - { - // Ignore samples - Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - return m.Success; - } + private static bool IsIgnored(ReadOnlySpan<char> filename) + => Regex.IsMatch(filename, @"\bsample\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static bool ContainsFile(IReadOnlyList<VideoInfo> result, FileSystemMetadata file) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs index e11fb262eb..9026160ff0 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; @@ -12,15 +10,20 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers { + /// <summary> + /// Class PhotoResolver. + /// </summary> public class PhotoResolver : ItemResolver<Photo> { private readonly IImageProcessor _imageProcessor; private readonly NamingOptions _namingOptions; + private readonly IDirectoryService _directoryService; private static readonly HashSet<string> _ignoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @@ -35,10 +38,17 @@ namespace Emby.Server.Implementations.Library.Resolvers "default" }; - public PhotoResolver(IImageProcessor imageProcessor, NamingOptions namingOptions) + /// <summary> + /// Initializes a new instance of the <see cref="PhotoResolver"/> class. + /// </summary> + /// <param name="imageProcessor">The image processor.</param> + /// <param name="namingOptions">The naming options.</param> + /// <param name="directoryService">The directory service.</param> + public PhotoResolver(IImageProcessor imageProcessor, NamingOptions namingOptions, IDirectoryService directoryService) { _imageProcessor = imageProcessor; _namingOptions = namingOptions; + _directoryService = directoryService; } /// <summary> @@ -61,7 +71,7 @@ namespace Emby.Server.Implementations.Library.Resolvers var filename = Path.GetFileNameWithoutExtension(args.Path); // Make sure the image doesn't belong to a video file - var files = args.DirectoryService.GetFiles(Path.GetDirectoryName(args.Path)); + var files = _directoryService.GetFiles(Path.GetDirectoryName(args.Path)); foreach (var file in files) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 7a2b3da3a9..5d569009d3 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -30,7 +30,7 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (args.IsDirectory) { - // It's a boxset if the path is a directory with [playlist] in it's the name + // It's a boxset if the path is a directory with [playlist] in its name var filename = Path.GetFileName(Path.TrimEndingDirectorySeparator(args.Path)); if (string.IsNullOrEmpty(filename)) { @@ -42,7 +42,8 @@ namespace Emby.Server.Implementations.Library.Resolvers return new Playlist { Path = args.Path, - Name = filename.Replace("[playlist]", string.Empty, StringComparison.OrdinalIgnoreCase).Trim() + Name = filename.Replace("[playlist]", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(), + OpenAccess = true }; } @@ -53,7 +54,8 @@ namespace Emby.Server.Implementations.Library.Resolvers return new Playlist { Path = args.Path, - Name = filename + Name = filename, + OpenAccess = true }; } } @@ -70,7 +72,8 @@ namespace Emby.Server.Implementations.Library.Resolvers Path = args.Path, Name = Path.GetFileNameWithoutExtension(args.Path), IsInMixedFolder = true, - PlaylistMediaType = MediaType.Audio + PlaylistMediaType = MediaType.Audio, + OpenAccess = true }; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index 0fcc5070b3..392ee4c771 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -5,6 +5,7 @@ using System.Linq; using Emby.Naming.Common; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -20,8 +21,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// </summary> /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> - public EpisodeResolver(ILogger<EpisodeResolver> logger, NamingOptions namingOptions) - : base(logger, namingOptions) + /// <param name="directoryService">The directory service.</param> + public EpisodeResolver(ILogger<EpisodeResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) + : base(logger, namingOptions, directoryService) { } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 62a524d2eb..e9538a5c97 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -81,14 +81,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV if (season.IndexNumber.HasValue) { var seasonNumber = season.IndexNumber.Value; - - season.Name = seasonNumber == 0 ? - args.LibraryOptions.SeasonZeroDisplayName : - string.Format( - CultureInfo.InvariantCulture, - _localization.GetLocalizedString("NameSeasonNumber"), - seasonNumber, - args.LibraryOptions.PreferredMetadataLanguage); + if (string.IsNullOrEmpty(season.Name)) + { + var seasonNames = series.SeasonNames; + if (seasonNames.TryGetValue(seasonNumber, out var seasonName)) + { + season.Name = seasonName; + } + else + { + season.Name = seasonNumber == 0 ? + args.LibraryOptions.SeasonZeroDisplayName : + string.Format( + CultureInfo.InvariantCulture, + _localization.GetLocalizedString("NameSeasonNumber"), + seasonNumber, + args.LibraryOptions.PreferredMetadataLanguage); + } + } } return season; diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index 8f69175d07..d4f275bed4 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -184,6 +184,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { var justName = Path.GetFileName(path.AsSpan()); + var imdbId = justName.GetAttributeValue("imdbid"); + if (!string.IsNullOrEmpty(imdbId)) + { + item.SetProviderId(MetadataProvider.Imdb, imdbId); + } + var tvdbId = justName.GetAttributeValue("tvdbid"); if (!string.IsNullOrEmpty(tvdbId)) { diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 1137625f44..2c3dc18574 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -46,10 +46,9 @@ namespace Emby.Server.Implementations.Library public Folder[] GetUserViews(UserViewQuery query) { var user = _userManager.GetUserById(query.UserId); - if (user is null) { - throw new ArgumentException("User Id specified in the query does not exist.", nameof(query)); + throw new ArgumentException("User id specified in the query does not exist.", nameof(query)); } var folders = _libraryManager.GetUserRootFolder() @@ -58,7 +57,6 @@ namespace Emby.Server.Implementations.Library .ToList(); var groupedFolders = new List<ICollectionFolder>(); - var list = new List<Folder>(); foreach (var folder in folders) @@ -66,6 +64,20 @@ namespace Emby.Server.Implementations.Library var collectionFolder = folder as ICollectionFolder; var folderViewType = collectionFolder?.CollectionType; + // Playlist library requires special handling because the folder only refrences user playlists + if (string.Equals(folderViewType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + { + var items = folder.GetItemList(new InternalItemsQuery(user) + { + ParentId = folder.ParentId + }); + + if (!items.Any(item => item.IsVisible(user))) + { + continue; + } + } + if (UserView.IsUserSpecific(folder)) { list.Add(_libraryManager.GetNamedView(user, folder.Name, folder.Id, folderViewType, null)); @@ -111,10 +123,10 @@ namespace Emby.Server.Implementations.Library if (query.IncludeExternalContent) { - var channelResult = _channelManager.GetChannelsInternal(new ChannelQuery + var channelResult = _channelManager.GetChannelsInternalAsync(new ChannelQuery { UserId = query.UserId - }); + }).GetAwaiter().GetResult(); var channels = channelResult.Items; @@ -132,14 +144,12 @@ namespace Emby.Server.Implementations.Library } var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList(); - var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews); return list .OrderBy(i => { var index = Array.IndexOf(orders, i.Id); - if (index == -1 && i is UserView view && !view.DisplayParentId.Equals(default)) @@ -286,7 +296,7 @@ namespace Emby.Server.Implementations.Library if (parents.Count == 0) { - return new List<BaseItem>(); + return Array.Empty<BaseItem>(); } if (includeItemTypes.Length == 0) diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 8edd8f66ae..b9d0f170ac 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -627,10 +627,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _timerProvider.Update(existingTimer); return Task.FromResult(existingTimer.Id); } - else - { - throw new ArgumentException("A scheduled recording already exists for this program."); - } + + throw new ArgumentException("A scheduled recording already exists for this program."); } info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); @@ -1866,8 +1864,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false); - string id; - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out id)) + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id)) { await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false); } @@ -2032,7 +2029,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var people = item.Id.Equals(default) ? new List<PersonInfo>() : _libraryManager.GetPeople(item); var directors = people - .Where(i => IsPersonType(i, PersonType.Director)) + .Where(i => i.IsType(PersonKind.Director)) .Select(i => i.Name) .ToList(); @@ -2042,7 +2039,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } var writers = people - .Where(i => IsPersonType(i, PersonType.Writer)) + .Where(i => i.IsType(PersonKind.Writer)) .Select(i => i.Name) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); @@ -2122,10 +2119,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private static bool IsPersonType(PersonInfo person, string type) - => string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) - || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase); - private LiveTvProgram GetProgramInfoFromCache(string programId) { var query = new InternalItemsQuery diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 3f7914d3bb..7645c6c52d 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -415,14 +415,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings { return null; } - else if (uri.IndexOf("http", StringComparison.OrdinalIgnoreCase) != -1) + + if (uri.IndexOf("http", StringComparison.OrdinalIgnoreCase) != -1) { return uri; } - else - { - return apiUrl + "/image/" + uri + "?token=" + token; - } + + return apiUrl + "/image/" + uri + "?token=" + token; } private static double GetAspectRatio(ImageDataDto i) @@ -463,10 +462,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings } StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); - foreach (ReadOnlySpan<char> i in programIds) + foreach (var i in programIds) { str.Append('"') - .Append(i.Slice(0, 10)) + .Append(i[..10]) .Append("\","); } @@ -570,15 +569,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings _tokens.TryAdd(username, savedToken); } - if (!string.IsNullOrEmpty(savedToken.Name) && !string.IsNullOrEmpty(savedToken.Value)) + if (!string.IsNullOrEmpty(savedToken.Name) + && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks)) { - if (long.TryParse(savedToken.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out long ticks)) + // If it's under 24 hours old we can still use it + if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks) { - // If it's under 24 hours old we can still use it - if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks) - { - return savedToken.Name; - } + return savedToken.Name; } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index e874990da1..066afb956b 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -137,32 +137,33 @@ namespace Emby.Server.Implementations.LiveTv.Listings private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) { - string episodeTitle = program.Episode?.Title; + string episodeTitle = program.Episode.Title; + var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); var programInfo = new ProgramInfo { ChannelId = program.ChannelId, EndDate = program.EndDate.UtcDateTime, - EpisodeNumber = program.Episode?.Episode, + EpisodeNumber = program.Episode.Episode, EpisodeTitle = episodeTitle, - Genres = program.Categories, + Genres = programCategories, StartDate = program.StartDate.UtcDateTime, Name = program.Title, Overview = program.Description, ProductionYear = program.CopyrightDate?.Year, - SeasonNumber = program.Episode?.Series, - IsSeries = program.Episode is not null, + SeasonNumber = program.Episode.Series, + IsSeries = program.Episode.Series is not null, IsRepeat = program.IsPreviouslyShown && !program.IsNew, IsPremiere = program.Premiere is not null, - IsKids = program.Categories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsMovie = program.Categories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsNews = program.Categories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsSports = program.Categories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source, HasImage = !string.IsNullOrEmpty(program.Icon?.Source), OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, CommunityRating = program.StarRating, - SeriesId = program.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) + SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) }; if (string.IsNullOrWhiteSpace(program.ProgramId)) @@ -243,7 +244,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings { Id = c.Id, Name = c.DisplayName, - ImageUrl = string.IsNullOrEmpty(c.Icon.Source) ? null : c.Icon.Source, + ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source, Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number }).ToList(); } diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index 4003468d0d..ee039ff0f7 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1312,20 +1312,19 @@ namespace Emby.Server.Implementations.LiveTv return 7; } - private QueryResult<BaseItem> GetEmbyRecordings(RecordingQuery query, DtoOptions dtoOptions, User user) + private async Task<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) { if (user is null) { return new QueryResult<BaseItem>(); } - var folderIds = GetRecordingFolders(user, true) - .Select(i => i.Id) - .ToList(); + var folders = await GetRecordingFoldersAsync(user, true).ConfigureAwait(false); + var folderIds = Array.ConvertAll(folders, x => x.Id); var excludeItemTypes = new List<BaseItemKind>(); - if (folderIds.Count == 0) + if (folderIds.Length == 0) { return new QueryResult<BaseItem>(); } @@ -1392,7 +1391,7 @@ namespace Emby.Server.Implementations.LiveTv { MediaTypes = new[] { MediaType.Video }, Recursive = true, - AncestorIds = folderIds.ToArray(), + AncestorIds = folderIds, IsFolder = false, IsVirtualItem = false, Limit = limit, @@ -1528,7 +1527,7 @@ namespace Emby.Server.Implementations.LiveTv } } - public QueryResult<BaseItemDto> GetRecordings(RecordingQuery query, DtoOptions options) + public async Task<QueryResult<BaseItemDto>> GetRecordingsAsync(RecordingQuery query, DtoOptions options) { var user = query.UserId.Equals(default) ? null @@ -1536,7 +1535,7 @@ namespace Emby.Server.Implementations.LiveTv RemoveFields(options); - var internalResult = GetEmbyRecordings(query, options, user); + var internalResult = await GetEmbyRecordingsAsync(query, options, user).ConfigureAwait(false); var returnArray = _dtoService.GetBaseItemDtos(internalResult.Items, options, user); @@ -2379,12 +2378,11 @@ namespace Emby.Server.Implementations.LiveTv return _tvDtoService.GetInternalProgramId(externalId); } - public List<BaseItem> GetRecordingFolders(User user) - { - return GetRecordingFolders(user, false); - } + /// <inheritdoc /> + public Task<BaseItem[]> GetRecordingFoldersAsync(User user) + => GetRecordingFoldersAsync(user, false); - private List<BaseItem> GetRecordingFolders(User user, bool refreshChannels) + private async Task<BaseItem[]> GetRecordingFoldersAsync(User user, bool refreshChannels) { var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders() .SelectMany(i => i.Locations) @@ -2396,14 +2394,16 @@ namespace Emby.Server.Implementations.LiveTv .OrderBy(i => i.SortName) .ToList(); - folders.AddRange(_channelManager.GetChannelsInternal(new MediaBrowser.Model.Channels.ChannelQuery + var channels = await _channelManager.GetChannelsInternalAsync(new MediaBrowser.Model.Channels.ChannelQuery { UserId = user.Id, IsRecordingsFolder = true, RefreshLatestChannelItems = refreshChannels - }).Items); + }).ConfigureAwait(false); + + folders.AddRange(channels.Items); - return folders.Cast<BaseItem>().ToList(); + return folders.Cast<BaseItem>().ToArray(); } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 5327b3d748..98bbc15406 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -14,6 +14,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -58,7 +59,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _socketFactory = socketFactory; _streamHelper = streamHelper; - _jsonOptions = JsonDefaults.Options; + _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); + _jsonOptions.Converters.Add(new JsonBoolNumberConverter()); } public string Name => "HD Homerun"; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index 81eb083f6f..7bc209d6bd 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) { using var client = new TcpClient(); - await client.ConnectAsync(remoteIp, HdHomeRunPort).ConfigureAwait(false); + await client.ConnectAsync(remoteIp, HdHomeRunPort, cancellationToken).ConfigureAwait(false); using var stream = client.GetStream(); return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs index 80d9d07247..3450f971fc 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs @@ -13,8 +13,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public LegacyHdHomerunChannelCommands(string url) { // parse url for channel and program - var regExp = new Regex(@"\/ch([0-9]+)-?([0-9]*)"); - var match = regExp.Match(url); + var match = Regex.Match(url, @"\/ch([0-9]+)-?([0-9]*)"); if (match.Success) { _channel = match.Groups[1].Value; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index bcb42e1626..acf3964c8c 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -30,12 +30,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost { - private static readonly string[] _disallowedSharedStreamExtensions = + private static readonly string[] _disallowedMimeTypes = { - ".mkv", - ".mp4", - ".m3u8", - ".mpd" + "video/x-matroska", + "video/mp4", + "application/vnd.apple.mpegurl", + "application/mpegurl", + "application/x-mpegurl", + "video/vnd.mpeg.dash.mpd" }; private readonly IHttpClientFactory _httpClientFactory; @@ -118,9 +120,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping) { - var extension = Path.GetExtension(mediaSource.Path) ?? string.Empty; + using var message = new HttpRequestMessage(HttpMethod.Head, mediaSource.Path); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(message, cancellationToken) + .ConfigureAwait(false); - if (!_disallowedSharedStreamExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + response.EnsureSuccessStatusCode(); + + if (!_disallowedMimeTypes.Contains(response.Content.Headers.ContentType?.ToString(), StringComparison.OrdinalIgnoreCase)) { return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index a423ec8f48..b418162304 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -122,9 +122,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var attributes = ParseExtInf(extInf, out string remaining); extInf = remaining; - if (attributes.TryGetValue("tvg-logo", out string value)) + if (attributes.TryGetValue("tvg-logo", out string tvgLogo)) { - channel.ImageUrl = value; + channel.ImageUrl = tvgLogo; + } + else if (attributes.TryGetValue("logo", out string logo)) + { + channel.ImageUrl = logo; } if (attributes.TryGetValue("group-title", out string groupTitle)) @@ -166,30 +170,25 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty; string numberString = null; - string attributeValue; - if (attributes.TryGetValue("tvg-chno", out attributeValue)) + if (attributes.TryGetValue("tvg-chno", out var attributeValue) + && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _)) { - if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) - { - numberString = attributeValue; - } + numberString = attributeValue; } if (!IsValidChannelNumber(numberString)) { if (attributes.TryGetValue("tvg-id", out attributeValue)) { - if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) + if (double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _)) { numberString = attributeValue; } - else if (attributes.TryGetValue("channel-id", out attributeValue)) + else if (attributes.TryGetValue("channel-id", out attributeValue) + && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _)) { - if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) - { - numberString = attributeValue; - } + numberString = attributeValue; } } @@ -207,7 +206,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' }); - if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) + if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _)) { numberString = numberPart.ToString(); } @@ -255,19 +254,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private static bool IsValidChannelNumber(string numberString) { - if (string.IsNullOrWhiteSpace(numberString) || - string.Equals(numberString, "-1", StringComparison.OrdinalIgnoreCase) || - string.Equals(numberString, "0", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!double.TryParse(numberString, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) + if (string.IsNullOrWhiteSpace(numberString) + || string.Equals(numberString, "-1", StringComparison.Ordinal) + || string.Equals(numberString, "0", StringComparison.Ordinal)) { return false; } - return true; + return double.TryParse(numberString, CultureInfo.InvariantCulture, out _); } private static string GetChannelName(string extInf, Dictionary<string, string> attributes) @@ -285,7 +279,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' }); - if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) + if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _)) { // channel.Number = number.ToString(); nameInExtInf = nameInExtInf.Substring(numberIndex + 1).Trim(new[] { ' ', '-' }); @@ -317,8 +311,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - var reg = new Regex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase); - var matches = reg.Matches(line); + var matches = Regex.Matches(line, @"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase); remaining = line; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index e84e1e074c..51f46f4dac 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -38,7 +38,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts _httpClientFactory = httpClientFactory; _appHost = appHost; OriginalStreamId = originalStreamId; - EnableStreamSharing = true; } public override async Task Open(CancellationToken openCancellationToken) @@ -59,39 +58,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None) .ConfigureAwait(false); - var contentType = response.Content.Headers.ContentType?.ToString() ?? string.Empty; - if (contentType.Contains("matroska", StringComparison.OrdinalIgnoreCase) - || contentType.Contains("mp4", StringComparison.OrdinalIgnoreCase) - || contentType.Contains("dash", StringComparison.OrdinalIgnoreCase) - || contentType.Contains("mpegURL", StringComparison.OrdinalIgnoreCase) - || contentType.Contains("text/", StringComparison.OrdinalIgnoreCase)) - { - // Close the stream without any sharing features - response.Dispose(); - return; - } - - SetTempFilePath("ts"); - var taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); _ = StartStreaming(response, taskCompletionSource, LiveStreamCancellationTokenSource.Token); - // OpenedMediaSource.Protocol = MediaProtocol.File; - // OpenedMediaSource.Path = tempFile; - // OpenedMediaSource.ReadAtNativeFramerate = true; - MediaSource.Path = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; MediaSource.Protocol = MediaProtocol.Http; - // OpenedMediaSource.Path = TempFilePath; - // OpenedMediaSource.Protocol = MediaProtocol.File; - - // OpenedMediaSource.Path = _tempFilePath; - // OpenedMediaSource.Protocol = MediaProtocol.File; - // OpenedMediaSource.SupportsDirectPlay = false; - // OpenedMediaSource.SupportsDirectStream = true; - // OpenedMediaSource.SupportsTranscoding = true; var res = await taskCompletionSource.Task.ConfigureAwait(false); if (!res) { @@ -108,15 +81,17 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts try { Logger.LogInformation("Beginning {StreamType} stream to {FilePath}", GetType().Name, TempFilePath); - using var message = response; - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await StreamHelper.CopyToAsync( - stream, - fileStream, - IODefaults.CopyToBufferSize, - () => Resolve(openTaskCompletionSource), - cancellationToken).ConfigureAwait(false); + using (response) + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await StreamHelper.CopyToAsync( + stream, + fileStream, + IODefaults.CopyToBufferSize, + () => Resolve(openTaskCompletionSource), + cancellationToken).ConfigureAwait(false); + } } catch (OperationCanceledException ex) { diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 56c4e7d39d..3af124678f 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -1,4 +1,127 @@ { - "Sync": "Сінхранізацыя", - "Playlists": "Плэйліст" + "Sync": "Сінхранізаваць", + "Playlists": "Плэйлісты", + "Latest": "Апошні", + "LabelIpAddressValue": "IP-адрас: {0}", + "ItemAddedWithName": "{0} быў дададзены ў бібліятэку", + "MessageApplicationUpdated": "Сервер Jellyfin абноўлены", + "NotificationOptionApplicationUpdateInstalled": "Абнаўленне прыкладання ўсталявана", + "PluginInstalledWithName": "{0} быў усталяваны", + "UserCreatedWithName": "Карыстальнік {0} быў створаны", + "Albums": "Альбомы", + "Application": "Прыкладанне", + "AuthenticationSucceededWithUserName": "{0} паспяхова аўтэнтыфікаваны", + "Channels": "Каналы", + "ChapterNameValue": "Раздзел {0}", + "Collections": "Калекцыі", + "Default": "Па змаўчанні", + "FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}", + "Folders": "Папкі", + "Favorites": "Абранае", + "External": "Знешні", + "Genres": "Жанры", + "HeaderContinueWatching": "Працягнуць прагляд", + "HeaderFavoriteAlbums": "Абраныя альбомы", + "HeaderFavoriteEpisodes": "Абраныя серыі", + "HeaderFavoriteShows": "Абраныя шоу", + "HeaderFavoriteSongs": "Абраныя песні", + "HeaderLiveTV": "Прамы эфір", + "HeaderAlbumArtists": "Выканаўцы альбома", + "LabelRunningTimeValue": "Працягласць: {0}", + "HomeVideos": "Хатнія відэа", + "ItemRemovedWithName": "{0} быў выдалены з бібліятэкі", + "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да {0}", + "Movies": "Фільмы", + "Music": "Музыка", + "MusicVideos": "Музычныя кліпы", + "NameInstallFailed": "Устаноўка {0} не атрымалася", + "NameSeasonNumber": "Сезон {0}", + "NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне прыкладання", + "NotificationOptionPluginInstalled": "Плагін усталяваны", + "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} дзён.", + "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія настроены на аўтаматычнае абнаўленне.", + "TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.", + "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субтытры на аснове канфігурацыі метададзеных.", + "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых змяненняў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць прадукцыйнасць.", + "TaskKeyframeExtractor": "Экстрактар ключавых кадраў", + "TasksApplicationCategory": "Прыкладанне", + "AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}", + "Books": "Кнігі", + "CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}", + "DeviceOfflineWithName": "{0} адключыўся", + "DeviceOnlineWithName": "{0} падлучаны", + "Forced": "Прымусова", + "HeaderRecordingGroups": "Групы запісаў", + "HeaderNextUp": "Наступнае", + "HeaderFavoriteArtists": "Абраныя выканаўцы", + "HearingImpaired": "Са слабым слыхам", + "Inherit": "Атрымаць у спадчыну", + "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера {0} абноўлена", + "MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена", + "MixedContent": "Змешаны змест", + "NameSeasonUnknown": "Невядомы сезон", + "NotificationOptionInstallationFailed": "Збой усталёўкі", + "NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.", + "NotificationOptionCameraImageUploaded": "Выява камеры запампавана", + "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыё спынена", + "NotificationOptionAudioPlayback": "Прайграванне аўдыё пачалося", + "NotificationOptionNewLibraryContent": "Дададзены новы кантэнт", + "NotificationOptionPluginError": "Збой плагіна", + "NotificationOptionPluginUninstalled": "Плагін выдалены", + "NotificationOptionTaskFailed": "Збой запланаванага задання", + "NotificationOptionUserLockedOut": "Карыстальнік заблакіраваны", + "NotificationOptionVideoPlayback": "Пачалося прайграванне відэа", + "NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена", + "ScheduledTaskFailedWithName": "{0} не атрымалася", + "ScheduledTaskStartedWithName": "{0} пачалося", + "ServerNameNeedsToBeRestarted": "{0} трэба перазапусціць", + "Shows": "Шоу", + "StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.", + "SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}", + "TvShows": "ТБ-шоу", + "Undefined": "Нявызначана", + "UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны", + "UserOnlineFromDevice": "{0} падключаны з {1}", + "UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}", + "UserStartedPlayingItemWithValues": "{0} грае {1} на {2}", + "UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}", + "ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку", + "ValueSpecialEpisodeName": "Спецэпізод - {0}", + "VersionNumber": "Версія {0}", + "TasksMaintenanceCategory": "Абслугоўванне", + "TasksLibraryCategory": "Медыятэка", + "TasksChannelsCategory": "Інтэрнэт-каналы", + "TaskCleanActivityLog": "Ачысціць журнал актыўнасці", + "TaskCleanCache": "Ачысціць кэш", + "TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.", + "TaskRefreshChapterImages": "Выняць выявы раздзелаў", + "TaskRefreshLibrary": "Сканіраваць медыятэку", + "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.", + "TaskCleanLogs": "Ачысціць часопіс", + "TaskRefreshPeople": "Абнавіць людзей", + "TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.", + "TaskUpdatePlugins": "Абнавіць плагіны", + "TaskCleanTranscode": "Ачысціць каталог перакадзіравання", + "TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.", + "TaskRefreshChannels": "Абнавіць каналы", + "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субтытры", + "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных спісаў прайгравання HLS. Гэта задача можа працаваць у працягу доўгага часу." } diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index c3fbe24082..005926231d 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -1,27 +1,27 @@ { "DeviceOnlineWithName": "{0}-এর সাথে সংযুক্ত হয়েছে", "DeviceOfflineWithName": "{0}-এর সাথে সংযোগ বিচ্ছিন্ন হয়েছে", - "Collections": "সংগ্রহ", + "Collections": "সংগ্রহশালা", "ChapterNameValue": "অধ্যায় {0}", - "Channels": "চ্যানেল", + "Channels": "চ্যানেলসমূহ", "CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে", - "Books": "বই", + "Books": "পুস্তকসমূহ", "AuthenticationSucceededWithUserName": "{0} অনুমোদন সফল", - "Artists": "শিল্পীরা", + "Artists": "শিল্পীগণ", "Application": "অ্যাপ্লিকেশন", - "Albums": "অ্যালবামগুলো", + "Albums": "অ্যালবামসমূহ", "HeaderFavoriteEpisodes": "প্রিব পর্বগুলো", "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", "HeaderContinueWatching": "দেখতে থাকুন", - "HeaderAlbumArtists": "এলবাম শিল্পীবৃন্দ", - "Genres": "শৈলী", - "Folders": "ফোল্ডারগুলো", + "HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ", + "Genres": "শৈলীধারাসমূহ", + "Folders": "ফোল্ডারসমূহ", "Favorites": "পছন্দসমূহ", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {0}", "VersionNumber": "সংস্করণ {0}", - "ValueSpecialEpisodeName": "বিশেষ - {0}", + "ValueSpecialEpisodeName": "বিশেষ পর্ব - {0}", "ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে", "UserStoppedPlayingItemWithValues": "{2}তে {1} বাজানো শেষ করেছেন {0}", "UserStartedPlayingItemWithValues": "{2}তে {1} বাজাচ্ছেন {0}", @@ -36,10 +36,10 @@ "User": "ব্যবহারকারী", "TvShows": "টিভি শোগুলো", "System": "সিস্টেম", - "Sync": "সিংক", + "Sync": "সমলয় স্থাপন", "SubtitleDownloadFailureFromForItem": "{2} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ", "StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।", - "Songs": "গানগুলো", + "Songs": "সঙ্গীতসমূহ", "Shows": "টিভি পর্ব", "ServerNameNeedsToBeRestarted": "{0} রিস্টার্ট করা প্রয়োজন", "ScheduledTaskStartedWithName": "{0} শুরু হয়েছে", @@ -49,8 +49,8 @@ "PluginUninstalledWithName": "{0} বাদ দেয়া হয়েছে", "PluginInstalledWithName": "{0} ইন্সটল করা হয়েছে", "Plugin": "প্লাগিন", - "Playlists": "প্লেলিস্ট", - "Photos": "ছবিগুলো", + "Playlists": "প্লে লিস্ট সমূহ", + "Photos": "চিত্রসমূহ", "NotificationOptionVideoPlaybackStopped": "ভিডিও চলা বন্ধ", "NotificationOptionVideoPlayback": "ভিডিও চলা শুরু হয়েছে", "NotificationOptionUserLockedOut": "ব্যবহারকারী ঢুকতে পারছে না", @@ -71,9 +71,9 @@ "NameSeasonUnknown": "সিজন অজানা", "NameSeasonNumber": "সিজন {0}", "NameInstallFailed": "{0} ইন্সটল ব্যর্থ", - "MusicVideos": "গানের ভিডিও", + "MusicVideos": "সঙ্গীত ভিডিয়ো সমূহ", "Music": "গান", - "Movies": "চলচ্চিত্র", + "Movies": "চলচ্চিত্রসমূহ", "MixedContent": "মিশ্র কন্টেন্ট", "MessageServerConfigurationUpdated": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে", "HeaderRecordingGroups": "রেকর্ডিং দল", @@ -117,5 +117,11 @@ "Forced": "জোরকরে", "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের কাজের হিসাব মুছে দিন খালি করুন.", "TaskCleanActivityLog": "কাজের ফাইল খালি করুন", - "Default": "প্রাথমিক" + "Default": "ডিফল্ট", + "HearingImpaired": "দুর্বল শ্রবণক্ষমতাধরদের জন্য", + "TaskOptimizeDatabaseDescription": "তথ্যভাণ্ডার সুবিন্যস্ত করে ও অব্যবহৃত জায়গা ছেড়ে দেয়। লাইব্রেরী স্ক্যান অথবা যেকোনো তথ্যভাণ্ডার পরিবর্তনের পর এই প্রক্রিয়া চালালে তথ্যভাণ্ডারের তথ্য প্রদান দ্রুততর হতে পারে।", + "External": "বাহ্যিক", + "TaskOptimizeDatabase": "তথ্যভাণ্ডার সুবিন্যাস", + "TaskKeyframeExtractor": "কি-ফ্রেম নিষ্কাশক", + "TaskKeyframeExtractorDescription": "ভিডিয়ো থেকে কি-ফ্রেম নিষ্কাশনের মাধ্যমে অধিকতর সঠিক HLS প্লে লিস্ট তৈরী করে। এই প্রক্রিয়া দীর্ঘ সময় ধরে চলতে পারে।" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 1966f69683..26290df4d8 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -5,7 +5,7 @@ "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "Books": "Llibres", - "CameraImageUploadedFrom": "S'ha pujat una nova imatge des de la camera desde {0}", + "CameraImageUploadedFrom": "S'ha pujat una nova imatge de càmera des de {0}", "Channels": "Canals", "ChapterNameValue": "Capítol {0}", "Collections": "Col·leccions", @@ -16,65 +16,65 @@ "Folders": "Carpetes", "Genres": "Gèneres", "HeaderAlbumArtists": "Artistes de l'àlbum", - "HeaderContinueWatching": "Continua Veient", - "HeaderFavoriteAlbums": "Àlbums Preferits", - "HeaderFavoriteArtists": "Artistes Predilectes", - "HeaderFavoriteEpisodes": "Episodis Predilectes", - "HeaderFavoriteShows": "Sèries Predilectes", - "HeaderFavoriteSongs": "Cançons Predilectes", - "HeaderLiveTV": "TV en Directe", + "HeaderContinueWatching": "Continuar veient", + "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 d'Enregistrament", - "HomeVideos": "Vídeos Domèstics", + "HeaderRecordingGroups": "Grups d'enregistrament", + "HomeVideos": "Vídeos domèstics", "Inherit": "Hereta", - "ItemAddedWithName": "{0} ha estat afegit a la biblioteca", - "ItemRemovedWithName": "{0} ha estat eliminat de la biblioteca", + "ItemAddedWithName": "{0} ha sigut afegit a la biblioteca", + "ItemRemovedWithName": "{0} ha sigut eliminat de la biblioteca", "LabelIpAddressValue": "Adreça IP: {0}", "LabelRunningTimeValue": "Temps en funcionament: {0}", - "Latest": "Darreres", - "MessageApplicationUpdated": "El Servidor de Jellyfin ha estat actualitzat", - "MessageApplicationUpdatedTo": "El Servidor de Jellyfin ha estat actualitzat a {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", - "MusicVideos": "Vídeos Musicals", + "MusicVideos": "Videoclips", "NameInstallFailed": "{0} instal·lació fallida", "NameSeasonNumber": "Temporada {0}", - "NameSeasonUnknown": "Temporada Desconeguda", - "NewVersionIsAvailable": "Una nova versió del Servidor Jellyfin està disponible per descarregar.", - "NotificationOptionApplicationUpdateAvailable": "Actualització d'aplicació disponible", - "NotificationOptionApplicationUpdateInstalled": "Actualització d'aplicació instal·lada", + "NameSeasonUnknown": "Temporada desconeguda", + "NewVersionIsAvailable": "Una nova versió del servidor de Jellyfin està disponible per a descarregar.", + "NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicació disponible", + "NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicació instal·lada", "NotificationOptionAudioPlayback": "Reproducció d'àudio iniciada", "NotificationOptionAudioPlaybackStopped": "Reproducció d'àudio aturada", "NotificationOptionCameraImageUploaded": "Imatge de càmera pujada", "NotificationOptionInstallationFailed": "Instal·lació fallida", "NotificationOptionNewLibraryContent": "Nou contingut afegit", - "NotificationOptionPluginError": "Un connector ha fallat", - "NotificationOptionPluginInstalled": "Connector instal·lat", - "NotificationOptionPluginUninstalled": "Connector desinstal·lat", - "NotificationOptionPluginUpdateInstalled": "Actualització de connector instal·lada", + "NotificationOptionPluginError": "Un complement ha fallat", + "NotificationOptionPluginInstalled": "Complement instal·lat", + "NotificationOptionPluginUninstalled": "Complement desinstal·lat", + "NotificationOptionPluginUpdateInstalled": "Actualització de complement instal·lada", "NotificationOptionServerRestartRequired": "Reinici del servidor requerit", "NotificationOptionTaskFailed": "Tasca programada fallida", - "NotificationOptionUserLockedOut": "Usuari tancat", - "NotificationOptionVideoPlayback": "Reproducció de video iniciada", - "NotificationOptionVideoPlaybackStopped": "Reproducció de video aturada", + "NotificationOptionUserLockedOut": "Usuari expulsat", + "NotificationOptionVideoPlayback": "Reproducció de vídeo iniciada", + "NotificationOptionVideoPlaybackStopped": "Reproducció de vídeo aturada", "Photos": "Fotos", "Playlists": "Llistes de reproducció", - "Plugin": "Connector", + "Plugin": "Complement", "PluginInstalledWithName": "{0} ha estat instal·lat", "PluginUninstalledWithName": "{0} ha estat desinstal·lat", "PluginUpdatedWithName": "{0} ha estat actualitzat", "ProviderValue": "Proveïdor: {0}", "ScheduledTaskFailedWithName": "{0} ha fallat", - "ScheduledTaskStartedWithName": "{0} iniciat", + "ScheduledTaskStartedWithName": "{0} s'ha iniciat", "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciat", "Shows": "Sèries", "Songs": "Cançons", - "StartupEmbyServerIsLoading": "El Servidor de Jellyfin està carregant. Si et plau, prova de nou ben aviat.", + "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho altre cop aviat.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Els subtítols no s'han pogut baixar de {0} per {1}", + "SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}", "Sync": "Sincronitzar", "System": "Sistema", "TvShows": "Sèries de TV", @@ -82,11 +82,11 @@ "UserCreatedWithName": "S'ha creat l'usuari {0}", "UserDeletedWithName": "L'usuari {0} ha estat eliminat", "UserDownloadingItemWithValues": "{0} està descarregant {1}", - "UserLockedOutWithName": "L'usuari {0} ha sigut tancat", + "UserLockedOutWithName": "L'usuari {0} ha sigut expulsat", "UserOfflineFromDevice": "{0} s'ha desconnectat de {1}", "UserOnlineFromDevice": "{0} està connectat des de {1}", "UserPasswordChangedWithName": "La contrasenya ha estat canviada per a l'usuari {0}", - "UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per {0}", + "UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}", "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1}", "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1}", "ValueHasBeenAddedToLibrary": "{0} ha sigut afegit a la teva biblioteca", @@ -94,14 +94,14 @@ "VersionNumber": "Versió {0}", "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.", "TaskDownloadMissingSubtitles": "Descarrega els subtítols que faltin", - "TaskRefreshChannelsDescription": "Actualitza la informació dels canals d'Internet.", - "TaskRefreshChannels": "Actualitza Canals", - "TaskCleanTranscodeDescription": "Elimina els arxius temporals de transcodificacions que tinguin més d'un dia.", + "TaskRefreshChannelsDescription": "Actualitza la informació dels canals d'internet.", + "TaskRefreshChannels": "Actualitza els canals", + "TaskCleanTranscodeDescription": "Elimina els arxius de transcodificacions que tinguin més d'un dia.", "TaskCleanTranscode": "Neteja les transcodificacions", - "TaskUpdatePluginsDescription": "Actualitza les extensions que estan configurades per actualitzar-se automàticament.", - "TaskUpdatePlugins": "Actualitza les extensions", + "TaskUpdatePluginsDescription": "Actualitza els connectors que estan configurats per a actualitzar-se automàticament.", + "TaskUpdatePlugins": "Actualitza els connectors", "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva mediateca.", - "TaskRefreshPeople": "Actualitza Persones", + "TaskRefreshPeople": "Actualitza les persones", "TaskCleanLogsDescription": "Esborra els logs que tinguin més de {0} dies.", "TaskCleanLogs": "Neteja els registres", "TaskRefreshLibraryDescription": "Escaneja la mediateca buscant fitxers nous i refresca les metadades.", @@ -110,12 +110,12 @@ "TaskRefreshChapterImages": "Extreure les imatges dels capítols", "TaskCleanCacheDescription": "Elimina els arxius temporals que ja no són necessaris per al servidor.", "TaskCleanCache": "Elimina arxius temporals", - "TasksChannelsCategory": "Canals d'Internet", + "TasksChannelsCategory": "Canals d'internet", "TasksApplicationCategory": "Aplicació", "TasksLibraryCategory": "Biblioteca", "TasksMaintenanceCategory": "Manteniment", "TaskCleanActivityLogDescription": "Eliminat entrades del registre d'activitats mes antigues que l'antiguitat configurada.", - "TaskCleanActivityLog": "Buidar Registre d'Activitat", + "TaskCleanActivityLog": "Buidar el registre d'activitat", "Undefined": "Indefinit", "Forced": "Forçat", "Default": "Per defecte", @@ -124,5 +124,5 @@ "TaskKeyframeExtractorDescription": "Extreu fotogrames clau dels fitxers de vídeo per crear llistes de reproducció HLS més precises. Aquesta tasca pot durar molt de temps.", "TaskKeyframeExtractor": "Extractor de fotogrames clau", "External": "Extern", - "HearingImpaired": "Discapacitat Auditiva" + "HearingImpaired": "Discapacitat auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json index 331c3d678f..794a8e4ce4 100644 --- a/Emby.Server.Implementations/Localization/Core/cy.json +++ b/Emby.Server.Implementations/Localization/Core/cy.json @@ -28,7 +28,7 @@ "NameSeasonNumber": "Tymor {0}", "MusicVideos": "Fideos Cerddoriaeth", "MixedContent": "Cynnwys amrywiol", - "HomeVideos": "Fideos Cartref", + "HomeVideos": "Genres", "HeaderNextUp": "Nesaf i Fyny", "HeaderFavoriteArtists": "Ffefryn Artistiaid", "HeaderFavoriteAlbums": "Ffefryn Albwmau", @@ -122,5 +122,6 @@ "TaskRefreshChapterImagesDescription": "Creu mân-luniau ar gyfer fideos sydd â phenodau.", "TaskRefreshChapterImages": "Echdynnu Lluniau Pennod", "TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.", - "TaskCleanCache": "Gwaghau Ffolder Cache" + "TaskCleanCache": "Gwaghau Ffolder Cache", + "HearingImpaired": "Nam ar y clyw" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 0d0d0c8136..1b6eecdcfe 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -1,9 +1,9 @@ { - "Albums": "Albummer", + "Albums": "Album", "AppDeviceValues": "App: {0}, Enhed: {1}", "Application": "Applikation", "Artists": "Kunstnere", - "AuthenticationSucceededWithUserName": "{0} succesfuldt autentificeret", + "AuthenticationSucceededWithUserName": "{0} er logget ind", "Books": "Bøger", "CameraImageUploadedFrom": "Et nyt kamerabillede er blevet uploadet fra {0}", "Channels": "Kanaler", @@ -11,17 +11,17 @@ "Collections": "Samlinger", "DeviceOfflineWithName": "{0} har afbrudt forbindelsen", "DeviceOnlineWithName": "{0} er forbundet", - "FailedLoginAttemptWithUserName": "Fejlet loginforsøg fra {0}", + "FailedLoginAttemptWithUserName": "Mislykket loginforsøg fra {0}", "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumkunstner", + "HeaderAlbumArtists": "Albums kunstnere", "HeaderContinueWatching": "Fortsæt afspilning", - "HeaderFavoriteAlbums": "Favoritalbummer", - "HeaderFavoriteArtists": "Favoritkunstnere", - "HeaderFavoriteEpisodes": "Favoritepisoder", - "HeaderFavoriteShows": "Favoritserier", - "HeaderFavoriteSongs": "Favoritsange", + "HeaderFavoriteAlbums": "Favorit albummer", + "HeaderFavoriteArtists": "Favorit kunstnere", + "HeaderFavoriteEpisodes": "Favorit afsnit", + "HeaderFavoriteShows": "Favorit serier", + "HeaderFavoriteSongs": "Favorit sange", "HeaderLiveTV": "Live-TV", "HeaderNextUp": "Næste", "HeaderRecordingGroups": "Optagelsesgrupper", @@ -39,90 +39,90 @@ "MixedContent": "Blandet indhold", "Movies": "Film", "Music": "Musik", - "MusicVideos": "Musik videoer", + "MusicVideos": "Musikvideoer", "NameInstallFailed": "{0} installationen mislykkedes", "NameSeasonNumber": "Sæson {0}", "NameSeasonUnknown": "Ukendt sæson", - "NewVersionIsAvailable": "En ny version af Jellyfin Server er tilgængelig til download.", - "NotificationOptionApplicationUpdateAvailable": "Opdatering til applikation tilgængelig", - "NotificationOptionApplicationUpdateInstalled": "Opdatering til applikation installeret", + "NewVersionIsAvailable": "En ny version af Jellyfin Server er tilgængelig.", + "NotificationOptionApplicationUpdateAvailable": "Opdatering til applikationen er tilgængelig", + "NotificationOptionApplicationUpdateInstalled": "Opdatering til applikationen blev installeret", "NotificationOptionAudioPlayback": "Lydafspilning påbegyndt", "NotificationOptionAudioPlaybackStopped": "Lydafspilning stoppet", "NotificationOptionCameraImageUploaded": "Kamerabillede uploadet", - "NotificationOptionInstallationFailed": "Installationen fejlede", + "NotificationOptionInstallationFailed": "Installationen mislykkedes", "NotificationOptionNewLibraryContent": "Nyt indhold tilføjet", - "NotificationOptionPluginError": "Pluginfejl", - "NotificationOptionPluginInstalled": "Plugin installeret", - "NotificationOptionPluginUninstalled": "Plugin afinstalleret", - "NotificationOptionPluginUpdateInstalled": "Opdatering til plugin installeret", - "NotificationOptionServerRestartRequired": "Genstart af server påkrævet", - "NotificationOptionTaskFailed": "Planlagt opgave fejlet", - "NotificationOptionUserLockedOut": "Bruger låst ude", + "NotificationOptionPluginError": "Plugin fejl", + "NotificationOptionPluginInstalled": "Plugin blev installeret", + "NotificationOptionPluginUninstalled": "Plugin blev afinstalleret", + "NotificationOptionPluginUpdateInstalled": "Opdatering til plugin blev installeret", + "NotificationOptionServerRestartRequired": "Genstart af serveren er påkrævet", + "NotificationOptionTaskFailed": "Planlagt opgave er fejlet", + "NotificationOptionUserLockedOut": "Bruger er låst ude", "NotificationOptionVideoPlayback": "Videoafspilning påbegyndt", - "NotificationOptionVideoPlaybackStopped": "Videoafspilning stoppet", - "Photos": "Fotoer", + "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} fejlet", - "ScheduledTaskStartedWithName": "{0} påbegyndt", + "ScheduledTaskFailedWithName": "{0} mislykkedes", + "ScheduledTaskStartedWithName": "{0} påbegyndte", "ServerNameNeedsToBeRestarted": "{0} skal genstartes", "Shows": "Serier", "Songs": "Sange", - "StartupEmbyServerIsLoading": "Jellyfin Server er i gang med at starte op. Prøv venligst igen om lidt.", + "StartupEmbyServerIsLoading": "Jellyfin Server er i gang med at starte. Forsøg igen om et øjeblik.", "SubtitleDownloadFailureForItem": "Fejlet i download af undertekster for {0}", - "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke downloades fra {0} til {1}", - "Sync": "Synk", + "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}", + "Sync": "Synkroniser", "System": "System", - "TvShows": "Tv-serier", + "TvShows": "TV-serier", "User": "Bruger", "UserCreatedWithName": "Bruger {0} er blevet oprettet", - "UserDeletedWithName": "Brugeren {0} er blevet slettet", - "UserDownloadingItemWithValues": "{0} downloader {1}", + "UserDeletedWithName": "Brugeren {0} er nu slettet", + "UserDownloadingItemWithValues": "{0} henter {1}", "UserLockedOutWithName": "Brugeren {0} er blevet låst ude", "UserOfflineFromDevice": "{0} har afbrudt fra {1}", "UserOnlineFromDevice": "{0} er online fra {1}", - "UserPasswordChangedWithName": "Adgangskode er ændret for bruger {0}", - "UserPolicyUpdatedWithName": "Brugerpolitik er blevet opdateret for {0}", + "UserPasswordChangedWithName": "Adgangskode er ændret for brugeren {0}", + "UserPolicyUpdatedWithName": "Brugerpolitikken er blevet opdateret for {0}", "UserStartedPlayingItemWithValues": "{0} har påbegyndt afspilning af {1}", "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 konfiguration.", - "TaskDownloadMissingSubtitles": "Download manglende undertekster", - "TaskUpdatePluginsDescription": "Downloader og installere opdateringer for plugins som er konfigureret til at opdatere automatisk.", + "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 indstillet til at blive opdateret automatisk.", "TaskUpdatePlugins": "Opdater Plugins", - "TaskCleanLogsDescription": "Sletter log filer som er mere end {0} dage gammle.", - "TaskCleanLogs": "Ryd Log Mappe", - "TaskRefreshLibraryDescription": "Scanner dit medie bibliotek for nye filer og opdaterer metadata.", + "TaskCleanLogsDescription": "Sletter log filer som er mere end {0} dage gamle.", + "TaskCleanLogs": "Ryd Log mappe", + "TaskRefreshLibraryDescription": "Scanner dit medie bibliotek for nye filer og opdateret metadata.", "TaskRefreshLibrary": "Scan Medie Bibliotek", - "TaskCleanCacheDescription": "Sletter cache filer som systemet ikke har brug for længere.", - "TaskCleanCache": "Ryd Cache Mappe", + "TaskCleanCacheDescription": "Sletter cache filer som systemet ikke længere bruger.", + "TaskCleanCache": "Ryd Cache mappe", "TasksChannelsCategory": "Internet Kanaler", "TasksApplicationCategory": "Applikation", "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Vedligeholdelse", - "TaskRefreshChapterImages": "Udtræk Kapitel billeder", + "TaskRefreshChapterImages": "Udtræk kapitel billeder", "TaskRefreshChapterImagesDescription": "Lav miniaturebilleder for videoer der har kapitler.", - "TaskRefreshChannelsDescription": "Genopfrisker internet kanal information.", - "TaskRefreshChannels": "Genopfrisk Kanaler", - "TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end en dag gammel.", - "TaskCleanTranscode": "Rengør Transcode Mappen", - "TaskRefreshPeople": "Genopfrisk Personer", - "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek.", - "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigureret alder.", + "TaskRefreshChannelsDescription": "Opdater internet kanal information.", + "TaskRefreshChannels": "Opdater Kanaler", + "TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end 1 dag gammel.", + "TaskCleanTranscode": "Tøm Transcode mappen", + "TaskRefreshPeople": "Opdater 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", "Undefined": "Udefineret", "Forced": "Tvunget", "Default": "Standard", - "TaskOptimizeDatabaseDescription": "Kompakter database og forkorter fri plads. Ved at køre denne proces efter at scanne biblioteket eller efter at ændre noget som kunne have indflydelse på databasen, kan forbedre ydeevne.", + "TaskOptimizeDatabaseDescription": "Komprimerer databasen og frigør plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen, for at højne ydeevnen.", "TaskOptimizeDatabase": "Optimér database", - "TaskKeyframeExtractorDescription": "Udtrækker billeder fra videofiler for at lave mere præcise HLS playlister. Denne opgave kan godt tage lang tid.", - "TaskKeyframeExtractor": "Billedramme udtrækker", + "TaskKeyframeExtractorDescription": "Udtrækker billeder fra videofiler for at lave mere præcise HLS playlister. Denne opgave kan tage lang tid.", + "TaskKeyframeExtractor": "Nøglebillede udtræk", "External": "Ekstern", "HearingImpaired": "Hørehæmmet" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 8ad9e8c716..8bd3c5defe 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -118,11 +118,11 @@ "TaskCleanActivityLog": "Borrar log de actividades", "Undefined": "Indefinido", "Forced": "Forzado", - "Default": "Por Defecto", + "Default": "Predeterminado", "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", "TaskOptimizeDatabase": "Optimización de base de datos", "External": "Externo", "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reprodución HLS más precisas. Esta tarea puede durar mucho tiempo.", "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", - "HearingImpaired": "Personas con discapacidad auditiva" + "HearingImpaired": "Discapacidad Auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index afffdf3bfa..f5636a0af0 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -31,7 +31,7 @@ "ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", "LabelRunningTimeValue": "Tiempo de funcionamiento: {0}", - "Latest": "Últimos", + "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", diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index d6078c9c6d..3d5c046336 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "Optimizar base de datos", "External": "Externo", "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reproducción HLS más precisas. Esta tarea puede durar mucho tiempo.", - "TaskKeyframeExtractor": "Extractor de Fotogramas Clave" + "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", + "HearingImpaired": "Discapacidad auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 026648af46..8e4bba25b6 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabaseDescription": "فشرده سازی پایگاه داده و باز کردن فضای آزاد.اجرای این گزینه بعد از اسکن کردن کتابخانه یا تغییرات دیگر که روی پایگاه داده تأثیر میگذارند میتواند کارایی را بهبود ببخشد.", "TaskKeyframeExtractorDescription": "فریم های کلیدی را از فایل های ویدئویی استخراج می کند تا لیست های پخش HLS دقیق تری ایجاد کند. این کار ممکن است برای مدت طولانی اجرا شود.", "TaskKeyframeExtractor": "استخراج کننده فریم کلیدی", - "External": "خارجی" + "External": "خارجی", + "HearingImpaired": "مشکل شنوایی" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index ec72d58dd6..8672cfb9ff 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -118,7 +118,7 @@ "TaskCleanActivityLogDescription": "Poistaa määritettyä ikää vanhemmat tapahtumat toimintahistoriasta.", "TaskCleanActivityLog": "Tyhjennä toimintahistoria", "Undefined": "Määrittelemätön", - "TaskOptimizeDatabaseDescription": "Tiivistää ja puhdistaa tietokannan. Tämän toiminnon suorittaminen kirjastojen skannauksen tai muiden tietokantaan liittyvien muutoksien jälkeen voi parantaa suorituskykyä.", + "TaskOptimizeDatabaseDescription": "Tiivistää ja puhdistaa tietokannan. Tämän toiminnon suorittaminen kirjastopäivityksen tai muiden mahdollisten tietokantamuutosten jälkeen voi parantaa suorituskykyä.", "TaskOptimizeDatabase": "Optimoi tietokanta", "TaskKeyframeExtractorDescription": "Purkaa videotiedostojen avainkuvat tarkempien HLS-toistolistojen luomiseksi. Tehtävä saattaa kestää huomattavan pitkään.", "TaskKeyframeExtractor": "Avainkuvien purkain", diff --git a/Emby.Server.Implementations/Localization/Core/fil.json b/Emby.Server.Implementations/Localization/Core/fil.json index 99839ae6e8..01b3e95fc3 100644 --- a/Emby.Server.Implementations/Localization/Core/fil.json +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -119,5 +119,9 @@ "Undefined": "Hindi tiyak", "Forced": "Sapilitan", "TaskOptimizeDatabaseDescription": "Iko-compact ang database at ita-truncate ang free space. Ang pagpapatakbo ng gawaing ito pagkatapos ng pag-scan sa library o paggawa ng iba pang mga pagbabago na nagpapahiwatig ng mga pagbabago sa database ay maaaring magpa-improve ng performance.", - "TaskOptimizeDatabase": "I-optimize ang database" + "TaskOptimizeDatabase": "I-optimize ang database", + "HearingImpaired": "Bingi", + "TaskKeyframeExtractor": "Tagabunot ng Keyframe", + "TaskKeyframeExtractorDescription": "Nagbubunot ng keyframe mula sa mga bidyo upang makabuo ng mas tumpak na HLS playlist. Maaaring matagal ito gawin.", + "External": "External" } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index bd8cec710b..ac9da1dd12 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -1,7 +1,7 @@ { "Albums": "Alben", "AppDeviceValues": "App: {0}, Gerät: {1}", - "Application": "Anwendung", + "Application": "Applikation", "Artists": "Künstler", "AuthenticationSucceededWithUserName": "{0} hat sich angemeldet", "Books": "Bücher", @@ -14,7 +14,7 @@ "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", "Favorites": "Favoriten", "Folders": "Ordner", - "Genres": "Genres", + "Genres": "Genre", "HeaderAlbumArtists": "Album-Künstler", "HeaderContinueWatching": "weiter schauen", "HeaderFavoriteAlbums": "Lieblingsalben", @@ -49,7 +49,7 @@ "NotificationOptionAudioPlayback": "Audiowedergab gstartet", "NotificationOptionAudioPlaybackStopped": "Audiwedergab gstoppt", "NotificationOptionCameraImageUploaded": "Foti ueglade", - "NotificationOptionInstallationFailed": "Installationsfehler", + "NotificationOptionInstallationFailed": "Installationsfähler", "NotificationOptionNewLibraryContent": "Nöie Inhaut hinzuegfüegt", "NotificationOptionPluginError": "Plugin-Fäuer", "NotificationOptionPluginInstalled": "Plugin installiert", @@ -120,5 +120,9 @@ "Forced": "Erzwungen", "Default": "Standard", "TaskOptimizeDatabase": "Datenbank optimieren", - "External": "Extern" + "External": "Extern", + "TaskOptimizeDatabaseDescription": "Kompromiert d Datenbank und trennt freie Speicherplatz. Durch die Ufagb cha d Leistig nach em ne Scan vor Bibliothek oder andere Ufgabe verbesseret werde.", + "HearingImpaired": "Hörgschädigti", + "TaskKeyframeExtractor": "Keyframe-Extraktor", + "TaskKeyframeExtractorDescription": "Extrahiert Keyframes us Videodateien zum erstelle vo genauere HLS Playliste. Die Ufgab cha für e langi Zyt laufe." } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 182b43ffca..47d3eeac5d 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -67,5 +67,61 @@ "Plugin": "प्लग-इन", "Playlists": "प्लेलिस्ट", "Photos": "तस्वीरें", - "External": "बाहरी" + "External": "बाहरी", + "PluginUpdatedWithName": "{0} अपडेट हुए", + "ScheduledTaskStartedWithName": "{0} शुरू हुए", + "Songs": "गाने", + "UserStartedPlayingItemWithValues": "{0} {2} पर {1} खेल रहे हैं", + "UserStoppedPlayingItemWithValues": "{0} ने {2} पर {1} खेलना खत्म किया", + "StartupEmbyServerIsLoading": "जेलीफ़िन सर्वर लोड हो रहा है। कृपया शीघ्र ही पुन: प्रयास करें।", + "ServerNameNeedsToBeRestarted": "{0} रीस्टार्ट करने की आवश्यकता है", + "UserCreatedWithName": "उपयोगकर्ता {0} बनाया गया", + "UserDownloadingItemWithValues": "{0} डाउनलोड हो रहा है", + "UserOfflineFromDevice": "{0} {1} से डिस्कनेक्ट हो गया है", + "Undefined": "अनिर्धारित", + "UserOnlineFromDevice": "{0} {1} से ऑनलाइन है", + "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": "असमेत अनुलेख को अवाहरति करें", + "TaskRefreshLibrary": "माध्यम संग्राहत को छाने", + "TaskCleanActivityLog": "क्रियाकलाप लॉग साफ करें", + "TasksChannelsCategory": "इंटरनेट प्रणाली", + "TasksApplicationCategory": "अनुप्रयोग", + "TaskRefreshPeople": "लोगोकी जानकारी ताज़ी करें", + "TaskKeyframeExtractor": "कीफ़्रेम एक्सट्रैक्टर", + "TaskCleanActivityLogDescription": "कॉन्फ़िगर की गई आयु से पुरानी गतिविधि लॉग प्रविष्टियां हटाता है।", + "TaskRefreshChapterImagesDescription": "अध्याय वाले वीडियो के लिए थंबनेल बनाता है।", + "TaskRefreshLibraryDescription": "नई फ़ाइलों के लिए आपकी मीडिया लाइब्रेरी को स्कैन करता है और मेटाडेटा को ताज़ा करता है।", + "TaskCleanLogs": "स्वच्छ लॉग निर्देशिका", + "TaskUpdatePluginsDescription": "प्लगइन्स के लिए अपडेट डाउनलोड और इंस्टॉल करें जो स्वचालित रूप से अपडेट करने के लिए कॉन्फ़िगर किए गए हैं।", + "TaskCleanTranscode": "स्वच्छ ट्रांसकोड निर्देशिका", + "TaskCleanTranscodeDescription": "एक दिन से अधिक पुरानी ट्रांसकोड फ़ाइलें हटाता है.", + "TaskRefreshChannelsDescription": "इंटरनेट चैनल की जानकारी को ताज़ा करता है।", + "TaskOptimizeDatabaseDescription": "डेटाबेस को कॉम्पैक्ट करता है और मुक्त स्थान को छोटा करता है। लाइब्रेरी को स्कैन करने के बाद इस कार्य को चलाने या अन्य परिवर्तन करने से जो डेटाबेस संशोधनों को लागू करते हैं, प्रदर्शन में सुधार कर सकते हैं।", + "TaskRefreshChannels": "इंटरनेट चैनल की जानकारी को ताज़ा करता है", + "TaskRefreshChapterImages": "अध्याय छवियाँ निकालें", + "TaskCleanLogsDescription": "{0} दिन से अधिक पुरानी लॉग फ़ाइलें हटाता है।", + "TaskCleanCacheDescription": "उन कैश फ़ाइलों को हटाता है जिनकी अब सिस्टम को आवश्यकता नहीं है।", + "TaskUpdatePlugins": "अद्यतन प्लगइन्स", + "TaskRefreshPeopleDescription": "आपकी मीडिया लाइब्रेरी में अभिनेताओं और निर्देशकों के लिए मेटाडेटा अपडेट करता है।", + "TaskCleanCache": "स्वच्छ कैश निर्देशिका", + "TaskDownloadMissingSubtitlesDescription": "मेटाडेटा कॉन्फ़िगरेशन के आधार पर लापता उपशीर्षक के लिए इंटरनेट खोजता है।", + "TaskKeyframeExtractorDescription": "अधिक सटीक एचएलएस प्लेलिस्ट बनाने के लिए वीडियो फ़ाइलों से मुख्य-फ़्रेम निकालता है। यह कार्य लंबे समय तक चल सकता है।" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 695c0f4048..87ce07da31 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -82,7 +82,7 @@ "MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui", "MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui", "FailedLoginAttemptWithUserName": "Gagal melakukan login dari {0}", - "CameraImageUploadedFrom": "Gambar kamera baru telah diunggah dari {0}", + "CameraImageUploadedFrom": "Sebuah gambar kamera baru telah diunggah dari {0}", "DeviceOfflineWithName": "{0} telah terputus", "DeviceOnlineWithName": "{0} telah terhubung", "NotificationOptionVideoPlaybackStopped": "Pemutaran video berhenti", diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index b262a8b424..a40f495061 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -107,5 +107,14 @@ "TasksApplicationCategory": "Forrit", "TasksLibraryCategory": "Miðlasafn", "TasksMaintenanceCategory": "Viðhald", - "Default": "Sjálfgefið" + "Default": "Sjálfgefið", + "TaskCleanActivityLog": "Hreinsa athafnaskrá", + "TaskRefreshPeople": "Endurnýja fólk", + "TaskDownloadMissingSubtitles": "Sækja texta sem vantar", + "TaskOptimizeDatabase": "Fínstilla gagnagrunn", + "Undefined": "Óskilgreint", + "TaskCleanLogsDescription": "Eyðir færslu skrám sem eru meira en {0} gömul.", + "TaskCleanLogs": "Hreinsa færslu skrá", + "TaskDownloadMissingSubtitlesDescription": "Leitar á netinu að texta sem vantar miðað við uppsetningu lýsigagna.", + "HearingImpaired": "Heyrnarskertur" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 7f616c35ad..7b059c68ea 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -37,8 +37,8 @@ "MessageNamedServerConfigurationUpdatedWithValue": "サーバー設定項目の {0} が更新されました", "MessageServerConfigurationUpdated": "サーバー設定が更新されました", "MixedContent": "ミックスコンテンツ", - "Movies": "ムービー", - "Music": "ミュージック", + "Movies": "映画", + "Music": "音楽", "MusicVideos": "ミュージックビデオ", "NameInstallFailed": "{0}のインストールに失敗しました", "NameSeasonNumber": "シーズン {0}", diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index e1c937b6cd..ce8d8fc322 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -20,9 +20,9 @@ "HeaderFavoriteAlbums": "Mėgstami Albumai", "HeaderFavoriteArtists": "Mėgstami Atlikėjai", "HeaderFavoriteEpisodes": "Mėgstamiausios serijos", - "HeaderFavoriteShows": "Mėgstamiausi serialai", - "HeaderFavoriteSongs": "Mėgstamos dainos", - "HeaderLiveTV": "TV gyvai", + "HeaderFavoriteShows": "Mėgstamiausios TV Laidos", + "HeaderFavoriteSongs": "Mėgstamos Dainos", + "HeaderLiveTV": "Tiesioginė TV", "HeaderNextUp": "Toliau eilėje", "HeaderRecordingGroups": "Įrašų grupės", "HomeVideos": "Namų vaizdo įrašai", diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index e460fd7197..f7b24412af 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -84,7 +84,7 @@ "CameraImageUploadedFrom": "Jauns kameras attēls ir ticis augšupielādēts no {0}", "Books": "Grāmatas", "Artists": "Izpildītāji", - "Albums": "Albūmi", + "Albums": "Albumi", "ProviderValue": "Provider: {0}", "HeaderFavoriteSongs": "Dziesmu Favorīti", "HeaderFavoriteShows": "Raidījumu Favorīti", @@ -120,5 +120,8 @@ "Default": "Noklusējuma", "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Uzdevum palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.", "TaskOptimizeDatabase": "Optimizēt datubāzi", - "External": "Ārējais" + "External": "Ārējais", + "HearingImpaired": "Ar dzirdes traucējumiem", + "TaskKeyframeExtractor": "Atslēgkadru Ekstraktors", + "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs." } diff --git a/Emby.Server.Implementations/Localization/Core/lzh.json b/Emby.Server.Implementations/Localization/Core/lzh.json new file mode 100644 index 0000000000..031a4dac76 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/lzh.json @@ -0,0 +1,6 @@ +{ + "Albums": "辑册", + "Artists": "艺人", + "AuthenticationSucceededWithUserName": "{0} 授之权矣", + "Books": "册" +} diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index acc7746c12..0620fbcdb0 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -119,5 +119,7 @@ "Genres": "വിഭാഗങ്ങൾ", "Channels": "ചാനലുകൾ", "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്കാൻ ചെയ്തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്തതിന് ശേഷം ഈ ടാസ്ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.", - "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക" + "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക", + "HearingImpaired": "കേൾവി തകരാറുകൾ", + "External": "പുറമേയുള്ള" } diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json index b2227e4543..a8fb26b91a 100644 --- a/Emby.Server.Implementations/Localization/Core/mr.json +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -122,5 +122,6 @@ "External": "बाहेरचा", "DeviceOnlineWithName": "{0} कनेक्ट झाले", "DeviceOfflineWithName": "{0} डिस्कनेक्ट झाला आहे", - "AuthenticationSucceededWithUserName": "{0} यशस्वीरित्या प्रमाणीकृत" + "AuthenticationSucceededWithUserName": "{0} यशस्वीरित्या प्रमाणीकृत", + "HearingImpaired": "कर्णबधीर" } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 3d54a5a950..b2293e4b60 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -39,7 +39,7 @@ "MixedContent": "Kandungan campuran", "Movies": "Filem-filem", "Music": "Muzik", - "MusicVideos": "Video muzik", + "MusicVideos": "Video Muzik", "NameInstallFailed": "{0} pemasangan gagal", "NameSeasonNumber": "Musim {0}", "NameSeasonUnknown": "Musim Tidak Diketahui", @@ -55,7 +55,7 @@ "NotificationOptionPluginInstalled": "Plugin telah dipasang", "NotificationOptionPluginUninstalled": "Plugin telah dinyahpasang", "NotificationOptionPluginUpdateInstalled": "Kemaskini plugin telah dipasang", - "NotificationOptionServerRestartRequired": "", + "NotificationOptionServerRestartRequired": "Perlu mulakan semula server", "NotificationOptionTaskFailed": "Kegagalan tugas berjadual", "NotificationOptionUserLockedOut": "Pengguna telah dikunci", "NotificationOptionVideoPlayback": "Ulangmain video bermula", @@ -109,5 +109,20 @@ "TaskRefreshLibrary": "Imbas Perpustakaan Media", "TaskRefreshChapterImagesDescription": "Membuat gambaran kecil untuk video yang mempunyai bab.", "TaskRefreshChapterImages": "Ekstrak Gambar-gambar Bab", - "TaskCleanCacheDescription": "Menghapuskan fail cache yang tidak lagi diperlukan oleh sistem." + "TaskCleanCacheDescription": "Menghapuskan fail cache yang tidak lagi diperlukan oleh sistem.", + "HearingImpaired": "Lemah Pendengaran", + "TaskRefreshPeopleDescription": "Kemas kini metadata untuk pelakon dan pengarah di dalam perpustakaan media.", + "TaskUpdatePluginsDescription": "Muat turun dan kemas kini plugin yang dikonfigurasi secara automatik.", + "TaskDownloadMissingSubtitlesDescription": "Cari sari kata yang hilang di internet, berdasarkan konfigurasi metadata.", + "TaskOptimizeDatabaseDescription": "Mampatkan pangkalan data dan potong ruang kosong. Pelaksanaan tugas ini selepas pengimbasan perpustakaan boleh membantu membaiki prestasi.", + "TaskRefreshChannels": "Segarkan Saluran-saluran", + "TaskUpdatePlugins": "Kemas kini plugin", + "TaskDownloadMissingSubtitles": "Muat turn sari kata yang tiada", + "TaskCleanTranscodeDescription": "Padam fail transkod yang lebih lama dari satu hari.", + "TaskRefreshChannelsDescription": "Segarkan maklumat saluran internet.", + "TaskCleanTranscode": "Bersihkan direktori transkod", + "External": "Luaran", + "TaskOptimizeDatabase": "Optimumkan pangkalan data", + "TaskKeyframeExtractor": "Ekstrak bingkai kunci", + "TaskKeyframeExtractorDescription": "Ekstrak bingkai kunci dari fail video untuk membina HLS playlist yang lebih tepat. Tugas ini mungkin perlukan masa yang panjang." } diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json index 4c8e820a5c..7c6b08fb36 100644 --- a/Emby.Server.Implementations/Localization/Core/ne.json +++ b/Emby.Server.Implementations/Localization/Core/ne.json @@ -109,5 +109,19 @@ "Sync": "समकालीन", "SubtitleDownloadFailureFromForItem": "उपशीर्षकहरू {0} बाट {1} को लागि डाउनलोड गर्न असफल", "PluginUpdatedWithName": "{0} अद्यावधिक गरिएको थियो", - "PluginUninstalledWithName": "{0} को स्थापना रद्द गरिएको थियो" + "PluginUninstalledWithName": "{0} को स्थापना रद्द गरिएको थियो", + "HearingImpaired": "सुन्न नसक्ने", + "TaskUpdatePluginsDescription": "स्वचालित रूपमा अद्यावधिक गर्न कन्फिगर गरिएका प्लगइनहरूका लागि अद्यावधिकहरू डाउनलोड र स्थापना गर्दछ।", + "TaskCleanTranscode": "सफा ट्रान्सकोड निर्देशिका", + "TaskCleanTranscodeDescription": "एक दिन भन्दा पुराना ट्रान्सकोड फाइलहरू मेटाउँछ।", + "TaskRefreshChannels": "च्यानलहरू ताजा गर्नुहोस्", + "TaskDownloadMissingSubtitlesDescription": "मेटाडेटा कन्फिगरेसनमा आधारित हराइरहेको उपशीर्षकहरूको लागि इन्टरनेट खोज्छ।", + "TaskOptimizeDatabase": "डेटाबेस अप्टिमाइज गर्नुहोस्", + "TaskOptimizeDatabaseDescription": "डाटाबेस कम्प्याक्ट र खाली ठाउँ काट्छ। पुस्तकालय स्क्यान गरेपछि वा डाटाबेस परिमार्जनलाई संकेत गर्ने अन्य परिवर्तनहरू गरेपछि यो कार्य चलाउँदा कार्यसम्पादनमा सुधार हुन सक्छ।", + "TaskKeyframeExtractorDescription": "थप सटीक एचएलएस प्लेलिस्टहरू सिर्जना गर्न भिडियो फाइलहरूबाट कीफ्रेमहरू निकाल्छ। यो कार्य लामो समय सम्म चल्न सक्छ।", + "TaskUpdatePlugins": "प्लगइनहरू अपडेट गर्नुहोस्", + "TaskRefreshPeopleDescription": "तपाईंको मिडिया लाइब्रेरीमा अभिनेता र निर्देशकहरूको लागि मेटाडेटा अपडेट गर्दछ।", + "TaskRefreshChannelsDescription": "इन्टरनेट च्यानल जानकारी ताजा गर्दछ।", + "TaskDownloadMissingSubtitles": "छुटेका उपशीर्षकहरू डाउनलोड गर्नुहोस्", + "TaskKeyframeExtractor": "कीफ्रेम एक्स्ट्रक्टर" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index e03747cbec..4eb00d2896 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "Nieuwe camera-afbeelding toegevoegd vanaf {0}", "Channels": "Kanalen", "ChapterNameValue": "Hoofdstuk {0}", - "Collections": "Verzamelingen", + "Collections": "Collecties", "DeviceOfflineWithName": "Verbinding met {0} is verbroken", "DeviceOnlineWithName": "{0} is verbonden", "FailedLoginAttemptWithUserName": "Mislukte inlogpoging van {0}", @@ -58,8 +58,8 @@ "NotificationOptionServerRestartRequired": "Server herstart nodig", "NotificationOptionTaskFailed": "Geplande taak mislukt", "NotificationOptionUserLockedOut": "Gebruiker is vergrendeld", - "NotificationOptionVideoPlayback": "Video gestart", - "NotificationOptionVideoPlaybackStopped": "Video gestopt", + "NotificationOptionVideoPlayback": "Afspelen van video gestart", + "NotificationOptionVideoPlaybackStopped": "Afspelen van video gestopt", "Photos": "Foto's", "Playlists": "Afspeellijsten", "Plugin": "Plug-in", @@ -95,26 +95,26 @@ "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar ontbrekende ondertiteling gebaseerd op metadataconfiguratie.", "TaskDownloadMissingSubtitles": "Ontbrekende ondertiteling downloaden", "TaskRefreshChannelsDescription": "Vernieuwt informatie van internet kanalen.", - "TaskRefreshChannels": "Vernieuw Kanalen", + "TaskRefreshChannels": "Kanalen vernieuwen", "TaskCleanTranscodeDescription": "Verwijdert transcode bestanden ouder dan 1 dag.", "TaskCleanLogs": "Logboekmap opschonen", "TaskCleanTranscode": "Transcoderingsmap opschonen", "TaskUpdatePluginsDescription": "Downloadt en installeert updates van plug-ins waarvoor automatisch bijwerken is ingeschakeld.", "TaskUpdatePlugins": "Plug-ins bijwerken", - "TaskRefreshPeopleDescription": "Update metadata for acteurs en regisseurs in de media bibliotheek.", + "TaskRefreshPeopleDescription": "Updatet metadata voor acteurs en regisseurs in je mediabibliotheek.", "TaskRefreshPeople": "Personen vernieuwen", "TaskCleanLogsDescription": "Verwijdert log bestanden ouder dan {0} dagen.", "TaskRefreshLibraryDescription": "Scant de mediabibliotheek op nieuwe bestanden en vernieuwt de metadata.", "TaskRefreshLibrary": "Mediabibliotheek scannen", - "TaskRefreshChapterImagesDescription": "Maakt thumbnails aan voor videos met hoofdstukken.", - "TaskRefreshChapterImages": "Hoofdstukafbeeldingen uitpakken", + "TaskRefreshChapterImagesDescription": "Maakt voorbeeldafbeedingen aan voor video's met hoofdstukken.", + "TaskRefreshChapterImages": "Hoofdstukafbeeldingen extraheren", "TaskCleanCacheDescription": "Verwijdert gecachte bestanden die het systeem niet langer nodig heeft.", "TaskCleanCache": "Cache-map opschonen", - "TasksChannelsCategory": "Internet Kanalen", + "TasksChannelsCategory": "Internetkanalen", "TasksApplicationCategory": "Toepassing", "TasksLibraryCategory": "Bibliotheek", "TasksMaintenanceCategory": "Onderhoud", - "TaskCleanActivityLogDescription": "Verwijdert activiteiten logs ouder dan de ingestelde tijd.", + "TaskCleanActivityLogDescription": "Verwijdert activiteitenlogs ouder dan de ingestelde leeftijd.", "TaskCleanActivityLog": "Activiteitenlogboek legen", "Undefined": "Niet gedefinieerd", "Forced": "Geforceerd", diff --git a/Emby.Server.Implementations/Localization/Core/or.json b/Emby.Server.Implementations/Localization/Core/or.json new file mode 100644 index 0000000000..0e9d81ee87 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/or.json @@ -0,0 +1,4 @@ +{ + "External": "ବହିଃସ୍ଥ", + "Genres": "ଧରଣ" +} diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index 4ac57b630d..1f982feaf4 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -28,22 +28,22 @@ "ValueHasBeenAddedToLibrary": "{0} ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਹੈ", "UserStoppedPlayingItemWithValues": "{0} ਨੇ {2} 'ਤੇ {1} ਖੇਡਣਾ ਪੂਰਾ ਕਰ ਲਿਆ ਹੈ", "UserStartedPlayingItemWithValues": "{0} {2} 'ਤੇ {1} ਖੇਡ ਰਿਹਾ ਹੈ", - "UserPolicyUpdatedWithName": "ਉਪਭੋਗਤਾ ਨੀਤੀ ਨੂੰ {0} ਲਈ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", - "UserPasswordChangedWithName": "ਪਾਸਵਰਡ ਯੂਜ਼ਰ ਲਈ ਬਦਲਿਆ ਗਿਆ ਹੈ {0}", - "UserOnlineFromDevice": "{0} ਤੋਂ isਨਲਾਈਨ ਹੈ {1}", + "UserPolicyUpdatedWithName": "ਵਰਤੋਂਕਾਰ ਨੀਤੀ ਨੂੰ {0} ਲਈ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "UserPasswordChangedWithName": "{0} ਵਰਤੋਂਕਾਰ ਲਈ ਪਾਸਵਰਡ ਬਦਲਿਆ ਗਿਆ ਸੀ", + "UserOnlineFromDevice": "{0} ਨੂੰ {1} ਤੋਂ ਆਨਲਾਈਨ ਹੈ", "UserOfflineFromDevice": "{0} ਤੋਂ ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ {1}", - "UserLockedOutWithName": "ਯੂਜ਼ਰ {0} ਨੂੰ ਲਾਕ ਆਉਟ ਕਰ ਦਿੱਤਾ ਗਿਆ ਹੈ", - "UserDownloadingItemWithValues": "{0} ਡਾ{ਨਲੋਡ ਕਰ ਰਿਹਾ ਹੈ {1}", - "UserDeletedWithName": "ਯੂਜ਼ਰ {0} ਨੂੰ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ", - "UserCreatedWithName": "ਯੂਜ਼ਰ {0} ਬਣਾਇਆ ਗਿਆ ਹੈ", - "User": "ਯੂਜ਼ਰ", + "UserLockedOutWithName": "ਵਰਤੋਂਕਾਰ {0} ਨੂੰ ਲਾਕ ਕੀਤਾ ਗਿਆ ਹੈ", + "UserDownloadingItemWithValues": "{0} {1} ਨੂੰ ਡਾਊਨਲੋਡ ਕਰ ਰਿਹਾ ਹੈ", + "UserDeletedWithName": "ਵਰਤੋਂਕਾਰ {0} ਨੂੰ ਹਟਾਇਆ ਗਿਆ", + "UserCreatedWithName": "ਵਰਤੋਂਕਾਰ {0} ਬਣਾਇਆ ਗਿਆ ਹੈ", + "User": "ਵਰਤੋਂਕਾਰ", "Undefined": "ਪਰਿਭਾਸ਼ਤ", - "TvShows": "ਟੀਵੀ ਸ਼ੋਅਜ਼", + "TvShows": "ਟੀਵੀ ਸ਼ੋਅ", "System": "ਸਿਸਟਮ", "Sync": "ਸਿੰਕ", - "SubtitleDownloadFailureFromForItem": "ਉਪਸਿਰਲੇਖ {1} ਲਈ {0} ਤੋਂ ਡਾ toਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ", - "StartupEmbyServerIsLoading": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ. ਕਿਰਪਾ ਕਰਕੇ ਜਲਦੀ ਹੀ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ.", - "Songs": "ਗਾਣੇਂ", + "SubtitleDownloadFailureFromForItem": "ਉਪਸਿਰਲੇਖ {1} ਲਈ {0} ਤੋਂ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ", + "StartupEmbyServerIsLoading": "Jellyfin ਸਰਵਰ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ। ਛੇਤੀ ਹੀ ਫ਼ੇਰ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", + "Songs": "ਗਾਣੇ", "Shows": "ਸ਼ੋਅ", "ServerNameNeedsToBeRestarted": "{0} ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", "ScheduledTaskStartedWithName": "{0} ਸ਼ੁਰੂ ਹੋਇਆ", @@ -57,12 +57,12 @@ "Photos": "ਫੋਟੋਆਂ", "NotificationOptionVideoPlaybackStopped": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਰੋਕਿਆ ਗਿਆ", "NotificationOptionVideoPlayback": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", - "NotificationOptionUserLockedOut": "ਉਪਭੋਗਤਾ ਨੂੰ ਲਾਕ ਆਉਟ ਕੀਤਾ ਗਿਆ", + "NotificationOptionUserLockedOut": "ਵਰਤੋਂਕਾਰ ਨੂੰ ਲਾਕ ਕੀਤਾ", "NotificationOptionTaskFailed": "ਨਿਰਧਾਰਤ ਕਾਰਜ ਅਸਫਲਤਾ", "NotificationOptionServerRestartRequired": "ਸਰਵਰ ਨੂੰ ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", "NotificationOptionPluginUpdateInstalled": "ਪਲੱਗਇਨ ਅਪਡੇਟ ਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ", "NotificationOptionPluginUninstalled": "ਪਲੱਗਇਨ ਅਣਇੰਸਟੌਲ ਕੀਤਾ", - "NotificationOptionPluginInstalled": "ਪਲੱਗਇਨ ਸਥਾਪਿਤ ਕੀਤਾ", + "NotificationOptionPluginInstalled": "ਪਲੱਗਇਨ ਇੰਸਟਾਲ ਕੀਤੀ", "NotificationOptionPluginError": "ਪਲੱਗਇਨ ਅਸਫਲ", "NotificationOptionNewLibraryContent": "ਨਵੀਂ ਸਮੱਗਰੀ ਸ਼ਾਮਲ ਕੀਤੀ ਗਈ", "NotificationOptionInstallationFailed": "ਇੰਸਟਾਲੇਸ਼ਨ ਅਸਫਲ", @@ -92,7 +92,7 @@ "HomeVideos": "ਘਰੇਲੂ ਵੀਡੀਓ", "HeaderRecordingGroups": "ਰਿਕਾਰਡਿੰਗ ਸਮੂਹ", "HeaderNextUp": "ਅੱਗੇ", - "HeaderLiveTV": "ਲਾਈਵ ਟੀ", + "HeaderLiveTV": "ਲਾਈਵ ਟੀਵੀ", "HeaderFavoriteSongs": "ਮਨਪਸੰਦ ਗਾਣੇ", "HeaderFavoriteShows": "ਮਨਪਸੰਦ ਸ਼ੋਅ", "HeaderFavoriteEpisodes": "ਮਨਪਸੰਦ ਐਪੀਸੋਡ", @@ -102,20 +102,22 @@ "HeaderAlbumArtists": "ਐਲਬਮ ਕਲਾਕਾਰ", "Genres": "ਸ਼ੈਲੀਆਂ", "Forced": "ਮਜਬੂਰ", - "Folders": "ਫੋਲਡਰਸ", + "Folders": "ਫੋਲਡਰ", "Favorites": "ਮਨਪਸੰਦ", - "FailedLoginAttemptWithUserName": "ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ {0}", + "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ", "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", "Default": "ਡਿਫੌਲਟ", "Collections": "ਸੰਗ੍ਰਹਿਣ", - "ChapterNameValue": "ਅਧਿਆਇ {0}", + "ChapterNameValue": "ਚੈਪਟਰ {0}", "Channels": "ਚੈਨਲ", - "CameraImageUploadedFrom": "ਤੋਂ ਇੱਕ ਨਵਾਂ ਕੈਮਰਾ ਚਿੱਤਰ ਅਪਲੋਡ ਕੀਤਾ ਗਿਆ ਹੈ {0}", + "CameraImageUploadedFrom": "{0} ਤੋਂ ਇੱਕ ਨਵਾਂ ਕੈਮਰਾ ਚਿੱਤਰ ਅਪਲੋਡ ਕੀਤਾ ਗਿਆ ਹੈ", "Books": "ਕਿਤਾਬਾਂ", "AuthenticationSucceededWithUserName": "{0} ਸਫਲਤਾਪੂਰਕ ਪ੍ਰਮਾਣਿਤ", "Artists": "ਕਲਾਕਾਰ", "Application": "ਐਪਲੀਕੇਸ਼ਨ", "AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}", - "Albums": "ਐਲਬਮਾਂ" + "Albums": "ਐਲਬਮਾਂ", + "TaskOptimizeDatabase": "ਡਾਟਾਬੇਸ ਅਨੁਕੂਲ ਬਣਾਓ", + "External": "ਬਾਹਰੀ" } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index 466c8a9905..87800a2fe8 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -19,5 +19,10 @@ "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}", "Favorites": "Finest Loot", "ItemRemovedWithName": "{0} was taken from yer treasure", - "LabelIpAddressValue": "Ship's coordinates: {0}" + "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" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 39229f45f9..2281e80c8a 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -121,5 +121,7 @@ "TaskOptimizeDatabase": "Otimizar base de dados", "TaskOptimizeDatabaseDescription": "Base de dados compacta e corta espaço livre. A execução desta tarefa depois de digitalizar a biblioteca ou de fazer outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", "External": "Externo", - "HearingImpaired": "Problemas auditivos" + "HearingImpaired": "Problemas auditivos", + "TaskKeyframeExtractor": "Extrator de quadro-chave", + "TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo." } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 65cf29e807..421513341a 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -16,14 +16,14 @@ "Folders": "Папки", "Genres": "Жанры", "HeaderAlbumArtists": "Исполнители альбома", - "HeaderContinueWatching": "Продолжение просмотра", + "HeaderContinueWatching": "Продолжить просмотр", "HeaderFavoriteAlbums": "Избранные альбомы", "HeaderFavoriteArtists": "Избранные исполнители", "HeaderFavoriteEpisodes": "Избранные эпизоды", "HeaderFavoriteShows": "Избранные сериалы", "HeaderFavoriteSongs": "Избранные композиции", "HeaderLiveTV": "Эфир", - "HeaderNextUp": "Очередное", + "HeaderNextUp": "Следующий", "HeaderRecordingGroups": "Группы записей", "HomeVideos": "Домашние видео", "Inherit": "Наследуемое", @@ -42,7 +42,7 @@ "MusicVideos": "Муз. видео", "NameInstallFailed": "Установка {0} неудачна", "NameSeasonNumber": "Сезон {0}", - "NameSeasonUnknown": "Сезон неопознан", + "NameSeasonUnknown": "Сезон не опознан", "NewVersionIsAvailable": "Новая версия Jellyfin Server доступна для загрузки.", "NotificationOptionApplicationUpdateAvailable": "Имеется обновление приложения", "NotificationOptionApplicationUpdateInstalled": "Обновление приложения установлено", @@ -70,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} - неудачна", "ScheduledTaskStartedWithName": "{0} - запущена", "ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}", - "Shows": "Передачи", + "Shows": "Телешоу", "Songs": "Композиции", "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", @@ -96,7 +96,7 @@ "TaskRefreshChannels": "Обновление каналов", "TaskCleanTranscode": "Очистка каталога перекодировки", "TaskUpdatePlugins": "Обновление плагинов", - "TaskRefreshPeople": "Подновление людей", + "TaskRefreshPeople": "Обновление информации о персонах", "TaskCleanLogs": "Очистка каталога журналов", "TaskRefreshLibrary": "Сканирование медиатеки", "TaskRefreshChapterImages": "Извлечение изображений сцен", diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index d845accac2..4c23f71efa 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimiziraj bazo podatkov", "TaskKeyframeExtractor": "Ekstraktor ključnih sličic", "External": "Zunanji", - "TaskKeyframeExtractorDescription": "Iz video datoteke Izvleče ključne sličice, da ustvari bolj natančne sezname predvajanja HLS. Proces lahko traja dolgo časa." + "TaskKeyframeExtractorDescription": "Iz video datoteke Izvleče ključne sličice, da ustvari bolj natančne sezname predvajanja HLS. Proces lahko traja dolgo časa.", + "HearingImpaired": "Oslabljen sluh" } diff --git a/Emby.Server.Implementations/Localization/Core/sn.json b/Emby.Server.Implementations/Localization/Core/sn.json new file mode 100644 index 0000000000..74720e7646 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/sn.json @@ -0,0 +1,28 @@ +{ + "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/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 318a0f3cf1..785e6b2262 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -66,7 +66,7 @@ "PluginInstalledWithName": "{0} installerades", "PluginUninstalledWithName": "{0} avinstallerades", "PluginUpdatedWithName": "{0} uppdaterades", - "ProviderValue": "Källa: {0}", + "ProviderValue": "Leverantör: {0}", "ScheduledTaskFailedWithName": "{0} misslyckades", "ScheduledTaskStartedWithName": "{0} startades", "ServerNameNeedsToBeRestarted": "{0} behöver startas om", diff --git a/Emby.Server.Implementations/Localization/Core/te.json b/Emby.Server.Implementations/Localization/Core/te.json index a9a8ceae0e..24168b6112 100644 --- a/Emby.Server.Implementations/Localization/Core/te.json +++ b/Emby.Server.Implementations/Localization/Core/te.json @@ -19,5 +19,24 @@ "Channels": "ఛానెల్లు", "Books": "పుస్తకాలు", "Artists": "కళాకారులు", - "Albums": "ఆల్బమ్లు" + "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": "రికార్డింగ్ గుంపులు" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index b802db9822..9a140f8712 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Veritabanını optimize et", "TaskKeyframeExtractorDescription": "Daha hassas HLS çalma listeleri oluşturmak için video dosyalarından kareleri çıkarır. Bu görev uzun bir süre çalışabilir.", "TaskKeyframeExtractor": "Kare Ayırt Edici", - "External": "Harici" + "External": "Harici", + "HearingImpaired": "Duyma engelli" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 92ce616f2e..ff77fb8c56 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -86,7 +86,7 @@ "Shows": "Шоу", "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити", "ScheduledTaskStartedWithName": "{0} розпочато", - "ScheduledTaskFailedWithName": "Помилка {0}", + "ScheduledTaskFailedWithName": "{0} незавершено, збій", "ProviderValue": "Постачальник: {0}", "PluginUpdatedWithName": "{0} оновлено", "PluginUninstalledWithName": "{0} видалено", diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index 7fe0c4c4be..5d3f194329 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -12,7 +12,7 @@ "HeaderContinueWatching": "دیکھنا جاری رکھیں", "Playlists": "پلے لسٹس", "ValueSpecialEpisodeName": "خصوصی - {0}", - "Shows": "دکھاتا ہے۔", + "Shows": "دکھاتا ہے", "Genres": "انواع", "Artists": "فنکار", "Sync": "مطابقت پذیری", @@ -123,5 +123,5 @@ "TaskCleanActivityLogDescription": "تشکیل شدہ عمر سے زیادہ پرانی سرگرمی لاگ اندراجات کو حذف کرتا ہے۔", "External": "بیرونی", "HearingImpaired": "قوت سماعت سے محروم", - "TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں۔" + "TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index ccfbeef0ce..03265d3fb0 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -14,7 +14,7 @@ "FailedLoginAttemptWithUserName": "从 {0} 尝试登录失败", "Favorites": "我的最爱", "Folders": "文件夹", - "Genres": "风格", + "Genres": "类型", "HeaderAlbumArtists": "专辑艺术家", "HeaderContinueWatching": "继续观看", "HeaderFavoriteAlbums": "收藏的专辑", diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index cdc25ec7c7..e8b8c2c5fa 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -4,18 +4,18 @@ "Application": "應用程式", "Artists": "藝人", "AuthenticationSucceededWithUserName": "{0} 授權成功", - "Books": "圖書", - "CameraImageUploadedFrom": "{0} 成功上傳一張新相片", + "Books": "書籍", + "CameraImageUploadedFrom": "{0} 成功上傳一張新照片", "Channels": "頻道", - "ChapterNameValue": "章節 {0}", - "Collections": "合輯", - "DeviceOfflineWithName": "{0} 已經斷開連接", - "DeviceOnlineWithName": "{0} 已經連接", + "ChapterNameValue": "第 {0} 章", + "Collections": "系列", + "DeviceOfflineWithName": "{0} 已斷開連接", + "DeviceOnlineWithName": "{0} 已連接", "FailedLoginAttemptWithUserName": "{0} 登入失敗", "Favorites": "我的最愛", "Folders": "資料夾", "Genres": "風格", - "HeaderAlbumArtists": "專輯藝人", + "HeaderAlbumArtists": "專輯歌手", "HeaderContinueWatching": "繼續觀看", "HeaderFavoriteAlbums": "最愛的專輯", "HeaderFavoriteArtists": "最愛的藝人", @@ -23,105 +23,105 @@ "HeaderFavoriteShows": "最愛的節目", "HeaderFavoriteSongs": "最愛的歌曲", "HeaderLiveTV": "電視直播", - "HeaderNextUp": "接下來", + "HeaderNextUp": "接著播放", "HeaderRecordingGroups": "錄製組", "HomeVideos": "家庭影片", "Inherit": "繼承", - "ItemAddedWithName": "{0} 已添加至媒體庫", + "ItemAddedWithName": "{0} 已被添加至媒體庫", "ItemRemovedWithName": "{0} 已從媒體庫移除", "LabelIpAddressValue": "IP 地址: {0}", "LabelRunningTimeValue": "運行時間: {0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin 伺服器已更新", - "MessageApplicationUpdatedTo": "Jellyfin 伺服器已更新至 {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 已更新", - "MessageServerConfigurationUpdated": "伺服器設定已經更新", + "MessageApplicationUpdated": "Jellyfin 已被更新", + "MessageApplicationUpdatedTo": "Jellyfin 已被更新至 {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 已被更新", + "MessageServerConfigurationUpdated": "伺服器設定已經被更新", "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", - "MusicVideos": "音樂影片", + "MusicVideos": "MV", "NameInstallFailed": "{0} 安裝失敗", "NameSeasonNumber": "第 {0} 季", - "NameSeasonUnknown": "未知季數", - "NewVersionIsAvailable": "新版本的 Jellyfin 伺服器可供下載。", + "NameSeasonUnknown": "未知的季度", + "NewVersionIsAvailable": "有較新版本的 Jellyfin 可供下載。", "NotificationOptionApplicationUpdateAvailable": "有可用的更新", - "NotificationOptionApplicationUpdateInstalled": "應用程式已更新", + "NotificationOptionApplicationUpdateInstalled": "應用程式已被更新", "NotificationOptionAudioPlayback": "開始播放音訊", - "NotificationOptionAudioPlaybackStopped": "已停止播放音訊", - "NotificationOptionCameraImageUploaded": "相片已上傳", + "NotificationOptionAudioPlaybackStopped": "停止播放音訊", + "NotificationOptionCameraImageUploaded": "相片已被上傳", "NotificationOptionInstallationFailed": "安裝失敗", "NotificationOptionNewLibraryContent": "已添加新内容", - "NotificationOptionPluginError": "擴充元件錯誤", - "NotificationOptionPluginInstalled": "擴充元件已安裝", - "NotificationOptionPluginUninstalled": "擴充元件已移除", - "NotificationOptionPluginUpdateInstalled": "擴充元件更新已安裝", - "NotificationOptionServerRestartRequired": "伺服器需要重啓", - "NotificationOptionTaskFailed": "計劃任務失敗", - "NotificationOptionUserLockedOut": "用家已鎖定", - "NotificationOptionVideoPlayback": "開始播放視頻", - "NotificationOptionVideoPlaybackStopped": "已停止播放視頻", + "NotificationOptionPluginError": "插件出現錯誤", + "NotificationOptionPluginInstalled": "插件已被安裝", + "NotificationOptionPluginUninstalled": "插件已被移除", + "NotificationOptionPluginUpdateInstalled": "插件已被更新", + "NotificationOptionServerRestartRequired": "伺服器需要重啟", + "NotificationOptionTaskFailed": "排程任務執行失敗", + "NotificationOptionUserLockedOut": "用戶已被鎖定", + "NotificationOptionVideoPlayback": "開始播放影片", + "NotificationOptionVideoPlaybackStopped": "已停止播放影片", "Photos": "相片", "Playlists": "播放清單", "Plugin": "插件", "PluginInstalledWithName": "已安裝 {0}", "PluginUninstalledWithName": "已移除 {0}", "PluginUpdatedWithName": "已更新 {0}", - "ProviderValue": "提供者: {0}", - "ScheduledTaskFailedWithName": "{0} 任務失敗", - "ScheduledTaskStartedWithName": "{0} 任務開始", - "ServerNameNeedsToBeRestarted": "{0} 需要重啓", + "ProviderValue": "提供者:{0}", + "ScheduledTaskFailedWithName": "{0} 執行失敗", + "ScheduledTaskStartedWithName": "{0} 開始執行", + "ServerNameNeedsToBeRestarted": "{0} 需要重啟", "Shows": "節目", "Songs": "歌曲", - "StartupEmbyServerIsLoading": "Jellyfin 伺服器載入中,請稍後再試。", + "StartupEmbyServerIsLoading": "正在載入 Jellyfin,請稍後再試。", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕", "Sync": "同步", "System": "系統", "TvShows": "電視節目", - "User": "使用者", - "UserCreatedWithName": "使用者 {0} 已創建", - "UserDeletedWithName": "使用者 {0} 已移除", + "User": "用戶", + "UserCreatedWithName": "用戶 {0} 已被建立", + "UserDeletedWithName": "用戶 {0} 已被移除", "UserDownloadingItemWithValues": "{0} 正在下載 {1}", "UserLockedOutWithName": "使用者 {0} 已被鎖定", - "UserOfflineFromDevice": "{0} 已從 {1} 斷開", - "UserOnlineFromDevice": "{0} 已連綫,來自 {1}", - "UserPasswordChangedWithName": "使用者 {0} 的密碼已變更", + "UserOfflineFromDevice": "{0} 從 {1} 斷開連接", + "UserOnlineFromDevice": "{0} 從 {1} 連線", + "UserPasswordChangedWithName": "{0} 的密碼已被變改", "UserPolicyUpdatedWithName": "使用者協議已更新為 {0}", "UserStartedPlayingItemWithValues": "{0} 正在 {2} 上播放 {1}", - "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}", - "ValueHasBeenAddedToLibrary": "{0} 已添加到你的媒體庫", + "UserStoppedPlayingItemWithValues": "{0} 已停止在 {2} 上播放 {1}", + "ValueHasBeenAddedToLibrary": "已添加 {0} 到你的媒體庫", "ValueSpecialEpisodeName": "特典 - {0}", - "VersionNumber": "版本{0}", - "TaskDownloadMissingSubtitles": "下載遺失的字幕", + "VersionNumber": "版本 {0}", + "TaskDownloadMissingSubtitles": "下載缺少的字幕", "TaskUpdatePlugins": "更新插件", "TasksApplicationCategory": "應用程式", - "TaskRefreshLibraryDescription": "掃描媒體庫以查找新文件並刷新metadata。", + "TaskRefreshLibraryDescription": "掃描媒體庫以加入新增檔案及重新載入 metadata。", "TasksMaintenanceCategory": "維護", - "TaskDownloadMissingSubtitlesDescription": "根據metadata配置在互聯網上搜索缺少的字幕。", - "TaskRefreshChannelsDescription": "刷新互聯網頻道信息。", - "TaskRefreshChannels": "刷新頻道", + "TaskDownloadMissingSubtitlesDescription": "根據元數據中的設定,在互聯網上搜索缺少的字幕。", + "TaskRefreshChannelsDescription": "重新載入網絡頻道的資訊。", + "TaskRefreshChannels": "重新載入頻道", "TaskCleanTranscodeDescription": "刪除超過一天的轉碼文件。", "TaskCleanTranscode": "清理轉碼目錄", - "TaskUpdatePluginsDescription": "下載並安裝配置為自動更新的插件的更新。", + "TaskUpdatePluginsDescription": "下載並更新能夠被自動更新的插件。", "TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的元數據。", - "TaskCleanLogsDescription": "刪除超過{0}天的日誌文件。", - "TaskCleanLogs": "清理日誌目錄", + "TaskCleanLogsDescription": "刪除超過{0}天的紀錄檔。", + "TaskCleanLogs": "清理紀錄檔目錄", "TaskRefreshLibrary": "掃描媒體庫", - "TaskRefreshChapterImagesDescription": "為帶有章節的視頻創建縮略圖。", + "TaskRefreshChapterImagesDescription": "為帶有章節的影片建立縮圖。", "TaskRefreshChapterImages": "提取章節圖像", "TaskCleanCacheDescription": "刪除系統不再需要的緩存文件。", "TaskCleanCache": "清理緩存目錄", - "TasksChannelsCategory": "互聯網頻道", + "TasksChannelsCategory": "網絡頻道", "TasksLibraryCategory": "庫", - "TaskRefreshPeople": "刷新人物", + "TaskRefreshPeople": "重新載入人物", "TaskCleanActivityLog": "清理活動記錄", "Undefined": "未定義", "Forced": "強制", "Default": "預設", "TaskOptimizeDatabaseDescription": "壓縮數據庫並截斷可用空間。在掃描媒體庫或執行其他數據庫的修改後運行此任務可能會提高性能。", "TaskOptimizeDatabase": "最佳化數據庫", - "TaskCleanActivityLogDescription": "刪除早於設定時間的日誌記錄。", - "TaskKeyframeExtractorDescription": "提取關鍵格以創建更準確的HLS播放列表。次指示可能用時很長。", + "TaskCleanActivityLogDescription": "刪除早於設定時間的活動記錄。", + "TaskKeyframeExtractorDescription": "提取關鍵幀以建立更準確的 HLS 播放列表。此工作或需要使用較長時間來完成。", "TaskKeyframeExtractor": "關鍵幀提取器", "External": "外部", "HearingImpaired": "聽力障礙" diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 4949c5ab6d..36f4df93d1 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -91,14 +91,14 @@ "HeaderRecordingGroups": "錄製組", "Inherit": "繼承", "SubtitleDownloadFailureFromForItem": "無法為 {1} 從 {0} 下載字幕", - "TaskDownloadMissingSubtitlesDescription": "透過中繼資料從網路上搜尋遺失的字幕。", + "TaskDownloadMissingSubtitlesDescription": "透過媒體資訊從網路上搜尋遺失的字幕。", "TaskDownloadMissingSubtitles": "下載遺失的字幕", "TaskRefreshChannels": "重新整理頻道", "TaskUpdatePlugins": "更新附加元件", "TaskRefreshPeople": "更新人物", "TaskCleanLogsDescription": "刪除超過 {0} 天的日誌文件。", "TaskCleanLogs": "清空日誌資料夾", - "TaskRefreshLibraryDescription": "重新掃描媒體庫的新檔案並更新中繼資料。", + "TaskRefreshLibraryDescription": "重新掃描媒體庫的新檔案並更新媒體資訊。", "TaskRefreshLibrary": "重新掃描媒體庫", "TaskRefreshChapterImages": "擷取章節圖片", "TaskCleanCacheDescription": "刪除系統已不需要的快取。", @@ -108,7 +108,7 @@ "TaskCleanTranscodeDescription": "刪除超過一天的轉碼檔案。", "TaskCleanTranscode": "清除轉碼資料夾", "TaskUpdatePluginsDescription": "為已設置為自動更新的附加元件下載並安裝更新。", - "TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的中繼資料。", + "TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的資訊。", "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。", "TasksChannelsCategory": "網路頻道", "TasksApplicationCategory": "應用程式", diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index b418c7877e..96f4353998 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.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Reflection; using System.Text.Json; using System.Threading.Tasks; @@ -25,7 +26,7 @@ namespace Emby.Server.Implementations.Localization private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; - private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated" }; + private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated", "nr" }; private readonly IServerConfigurationManager _configurationManager; private readonly ILogger<LocalizationManager> _logger; @@ -86,12 +87,10 @@ namespace Emby.Server.Implementations.Localization var name = parts[0]; dict.Add(name, new ParentalRating(name, value)); } -#if DEBUG else { _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); } -#endif } _allParentalRatings[countryCode] = dict; @@ -184,80 +183,149 @@ namespace Emby.Server.Implementations.Localization /// <inheritdoc /> public IEnumerable<ParentalRating> GetParentalRatings() - => GetParentalRatingsDictionary().Values; - - /// <summary> - /// Gets the parental ratings dictionary. - /// </summary> - /// <returns><see cref="Dictionary{String, ParentalRating}" />.</returns> - private Dictionary<string, ParentalRating> GetParentalRatingsDictionary() { - var countryCode = _configurationManager.Configuration.MetadataCountryCode; + // Use server default language for ratings + // Fall back to empty list if there are no parental ratings for that language + var ratings = GetParentalRatingsDictionary()?.Values.ToList() + ?? new List<ParentalRating>(); + + // Add common ratings to ensure them being available for selection + // Based on the US rating system due to it being the main source of rating in the metadata providers + // Unrated + if (!ratings.Any(x => x.Value is null)) + { + ratings.Add(new ParentalRating("Unrated", null)); + } - if (string.IsNullOrEmpty(countryCode)) + // Minimum rating possible + if (ratings.All(x => x.Value != 0)) + { + ratings.Add(new ParentalRating("Approved", 0)); + } + + // Matches PG (this has different age restrictions depending on country) + if (ratings.All(x => x.Value != 10)) + { + ratings.Add(new ParentalRating("10", 10)); + } + + // Matches PG-13 + if (ratings.All(x => x.Value != 13)) + { + ratings.Add(new ParentalRating("13", 13)); + } + + // Matches TV-14 + if (ratings.All(x => x.Value != 14)) + { + ratings.Add(new ParentalRating("14", 14)); + } + + // Catchall if max rating of country is less than 21 + // Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned + if (!ratings.Any(x => x.Value >= 21)) + { + ratings.Add(new ParentalRating("21", 21)); + } + + // A lot of countries don't excplicitly have a seperate rating for adult content + if (ratings.All(x => x.Value != 1000)) + { + ratings.Add(new ParentalRating("XXX", 1000)); + } + + // A lot of countries don't excplicitly have a seperate rating for banned content + if (ratings.All(x => x.Value != 1001)) { - countryCode = "us"; + ratings.Add(new ParentalRating("Banned", 1001)); } - return GetRatings(countryCode) - ?? GetRatings("us") - ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); + return ratings.OrderBy(r => r.Value); } /// <summary> - /// Gets the ratings. + /// Gets the parental ratings dictionary. /// </summary> - /// <param name="countryCode">The country code.</param> - /// <returns>The ratings.</returns> - private Dictionary<string, ParentalRating>? GetRatings(string countryCode) + /// <param name="countryCode">The optional two letter ISO language string.</param> + /// <returns><see cref="Dictionary{String, ParentalRating}" />.</returns> + private Dictionary<string, ParentalRating>? GetParentalRatingsDictionary(string? countryCode = null) { - _allParentalRatings.TryGetValue(countryCode, out var value); + // Fallback to server default if no country code is specified. + if (string.IsNullOrEmpty(countryCode)) + { + countryCode = _configurationManager.Configuration.MetadataCountryCode; + } - return value; + if (_allParentalRatings.TryGetValue(countryCode, out var countryValue)) + { + return countryValue; + } + + return null; } /// <inheritdoc /> - public int? GetRatingLevel(string rating) + public int? GetRatingLevel(string rating, string? countryCode = null) { ArgumentException.ThrowIfNullOrEmpty(rating); + // Handle unrated content if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) { return null; } // Fairly common for some users to have "Rated R" in their rating field + rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase); rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase); - var ratingsDictionary = GetParentalRatingsDictionary(); - - if (ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) + // Use rating system matching the language + if (!string.IsNullOrEmpty(countryCode)) { - return value.Value; + var ratingsDictionary = GetParentalRatingsDictionary(countryCode); + if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) + { + return value.Value; + } + } + else + { + // Fall back to server default language for ratings check + // If it has no ratings, use the US ratings + var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us"); + if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) + { + return value.Value; + } } - // If we don't find anything check all ratings systems + // If we don't find anything, check all ratings systems foreach (var dictionary in _allParentalRatings.Values) { - if (dictionary.TryGetValue(rating, out value)) + if (dictionary.TryGetValue(rating, out var value)) { return value.Value; } } - // Try splitting by : to handle "Germany: FSK 18" - var index = rating.IndexOf(':', StringComparison.Ordinal); - if (index != -1) + // Try splitting by : to handle "Germany: FSK-18" + if (rating.Contains(':', StringComparison.OrdinalIgnoreCase)) { - var trimmedRating = rating.AsSpan(index).TrimStart(':').Trim(); + return GetRatingLevel(rating.AsSpan().RightPart(':').ToString()); + } - if (!trimmedRating.IsEmpty) - { - return GetRatingLevel(trimmedRating.ToString()); - } + // Handle prefix country code to handle "DE-18" + if (rating.Contains('-', StringComparison.OrdinalIgnoreCase)) + { + var ratingSpan = rating.AsSpan(); + + // Extract culture from country prefix + var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString()); + + // Check rating system of culture + return GetRatingLevel(ratingSpan.RightPart('-').ToString(), culture?.TwoLetterISOLanguageName); } - // TODO: Further improve by normalizing out all spaces and dashes return null; } diff --git a/Emby.Server.Implementations/Localization/Ratings/0-prefer.csv b/Emby.Server.Implementations/Localization/Ratings/0-prefer.csv new file mode 100644 index 0000000000..36886ba760 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/0-prefer.csv @@ -0,0 +1,11 @@ +E,0 +EC,0 +T,7 +M,18 +AO,18 +UR,18 +RP,18 +X,1000 +XX,1000 +XXX,1000 +XXXX,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/au.csv b/Emby.Server.Implementations/Localization/Ratings/au.csv index 11f4ed94cd..4ab808ae9a 100644 --- a/Emby.Server.Implementations/Localization/Ratings/au.csv +++ b/Emby.Server.Implementations/Localization/Ratings/au.csv @@ -1,7 +1,13 @@ -AU-G,1 -AU-PG,5 -AU-M,6 -AU-MA15+,7 -AU-R18+,9 -AU-X18+,10 -AU-RC,11 +Exempt,0 +G,0 +7+,7 +M,15 +MA,15 +MA15+,15 +PG,16 +16+,16 +R,18 +R18+,18 +X18+,18 +18+,18 +X,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/be.csv b/Emby.Server.Implementations/Localization/Ratings/be.csv index d3937caf78..d171a71328 100644 --- a/Emby.Server.Implementations/Localization/Ratings/be.csv +++ b/Emby.Server.Implementations/Localization/Ratings/be.csv @@ -1,6 +1,11 @@ -BE-AL,1 -BE-MG6,2 -BE-6,3 -BE-9,5 -BE-12,6 -BE-16,8 +AL,0 +KT,0 +TOUS,0 +MG6,6 +6,6 +9,9 +KNT,12 +12,12 +14,14 +16,16 +18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/br.csv b/Emby.Server.Implementations/Localization/Ratings/br.csv index e5edaf62cf..5ec1eb2627 100644 --- a/Emby.Server.Implementations/Localization/Ratings/br.csv +++ b/Emby.Server.Implementations/Localization/Ratings/br.csv @@ -1,6 +1,8 @@ -BR-L,1 -BR-10,5 -BR-12,7 -BR-14,8 -BR-16,8 -BR-18,9 +Livre,0 +L,0 +ER,9 +10,10 +12,12 +14,14 +16,16 +18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.csv b/Emby.Server.Implementations/Localization/Ratings/ca.csv index 5aef0580f8..336ee28067 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ca.csv +++ b/Emby.Server.Implementations/Localization/Ratings/ca.csv @@ -1,6 +1,20 @@ -CA-G,1 -CA-PG,5 -CA-14A,7 -CA-A,8 -CA-18A,9 -CA-R,10 +E,0 +G,0 +TV-Y,0 +TV-G,0 +TV-Y7,7 +TV-Y7-FV,7 +PG,9 +TV-PG,9 +PG-13,13 +13+,13 +TV-14,14 +14A,14 +16+,16 +NC-17,17 +R,18 +TV-MA,18 +18A,18 +18+,18 +A,1000 +Prohibited,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/co.csv b/Emby.Server.Implementations/Localization/Ratings/co.csv index 9684fa0524..e1e96c5909 100644 --- a/Emby.Server.Implementations/Localization/Ratings/co.csv +++ b/Emby.Server.Implementations/Localization/Ratings/co.csv @@ -1,8 +1,7 @@ -CO-T,1 -CO-7,5 -CO-12,7 -CO-15,8 -CO-18,10 -CO-X,100 -CO-BANNED,15 -CO-E,15 +T,0 +7,7 +12,12 +15,15 +18,18 +X,1000 +Prohibited,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/de.csv b/Emby.Server.Implementations/Localization/Ratings/de.csv index f944a140d0..d633a5dab7 100644 --- a/Emby.Server.Implementations/Localization/Ratings/de.csv +++ b/Emby.Server.Implementations/Localization/Ratings/de.csv @@ -1,10 +1,12 @@ -DE-0,1 -FSK-0,1 -DE-6,5 -FSK-6,5 -DE-12,7 -FSK-12,7 -DE-16,8 -FSK-16,8 -DE-18,9 -FSK-18,9 +Educational,0 +Infoprogramm,0 +FSK-0,0 +0,0 +FSK-6,6 +6,6 +FSK-12,12 +12,12 +FSK-16,16 +16,16 +FSK-18,18 +18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/dk.csv b/Emby.Server.Implementations/Localization/Ratings/dk.csv index 5364ae1f27..4ef63b2eac 100644 --- a/Emby.Server.Implementations/Localization/Ratings/dk.csv +++ b/Emby.Server.Implementations/Localization/Ratings/dk.csv @@ -1,4 +1,7 @@ -DA-A,1 -DA-7,5 -DA-11,6 -DA-15,8 +F,0 +A,0 +7,7 +11,11 +12,12 +15,15 +16,16 diff --git a/Emby.Server.Implementations/Localization/Ratings/es.csv b/Emby.Server.Implementations/Localization/Ratings/es.csv index 887d91ba63..0bc1d3f7d0 100644 --- a/Emby.Server.Implementations/Localization/Ratings/es.csv +++ b/Emby.Server.Implementations/Localization/Ratings/es.csv @@ -1,6 +1,24 @@ -ES-A,1 -ES-APTA,1 -ES-7,3 -ES-12,6 -ES-16,8 -ES-18,11 +A,0 +A/fig,0 +A/i,0 +A/fig/i,0 +APTA,0 +TP,0 +0+,0 +6+,6 +7/fig,7 +7/i,7 +7/i/fig,7 +7,7 +9+,9 +10,10 +12,12 +12/fig,12 +13,13 +14,14 +16,16 +16/fig,16 +18,18 +18/fig,18 +X,1000 +Banned,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/fi.csv b/Emby.Server.Implementations/Localization/Ratings/fi.csv index 782785890f..7ff92f259b 100644 --- a/Emby.Server.Implementations/Localization/Ratings/fi.csv +++ b/Emby.Server.Implementations/Localization/Ratings/fi.csv @@ -1,10 +1,10 @@ -FI-S,1 -FI-T,1 -FI-7,4 -FI-12,5 -FI-16,8 -FI-18,9 -FI-K7,4 -FI-K12,5 -FI-K16,8 -FI-K18,9 +S,0 +T,0 +K7,7 +7,7 +K12,12 +12,12 +K16,16 +16,16 +K18,18 +18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.csv b/Emby.Server.Implementations/Localization/Ratings/fr.csv index f586a3fa91..774a705891 100644 --- a/Emby.Server.Implementations/Localization/Ratings/fr.csv +++ b/Emby.Server.Implementations/Localization/Ratings/fr.csv @@ -1,5 +1,12 @@ -FR-U,1 -FR-10,5 -FR-12,7 -FR-16,9 -FR-18,10 +Public Averti,0 +Tous Publics,0 +U,0 +0+,0 +6+,6 +9+,9 +10,10 +12,12 +14+,14 +16,16 +18,18 +X,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/gb.csv b/Emby.Server.Implementations/Localization/Ratings/gb.csv index c1f7d04529..75b1c20589 100644 --- a/Emby.Server.Implementations/Localization/Ratings/gb.csv +++ b/Emby.Server.Implementations/Localization/Ratings/gb.csv @@ -1,7 +1,22 @@ -GB-U,1 -GB-PG,5 -GB-12,6 -GB-12A,7 -GB-15,8 -GB-18,9 -GB-R18,15 +All,0 +E,0 +G,0 +U,0 +0+,0 +6+,6 +7+,7 +PG,8 +9+,9 +12,12 +12+,12 +12A,12 +Teen,13 +13+,13 +14+,14 +15,15 +16,16 +Caution,18 +18,18 +Mature,1000 +Adult,1000 +R18,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/ie.csv b/Emby.Server.Implementations/Localization/Ratings/ie.csv index e42be5cd49..6ef2e50128 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ie.csv +++ b/Emby.Server.Implementations/Localization/Ratings/ie.csv @@ -1,6 +1,9 @@ -IE-G,1 -IE-PG,5 -IE-12A,7 -IE-15A,8 -IE-16,9 -IE-18,10 +G,4 +PG,12 +12,12 +12A,12 +12PG,12 +15,15 +15A,15 +16,16 +18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/jp.csv b/Emby.Server.Implementations/Localization/Ratings/jp.csv index a8fc2d1431..bfb5fdaae9 100644 --- a/Emby.Server.Implementations/Localization/Ratings/jp.csv +++ b/Emby.Server.Implementations/Localization/Ratings/jp.csv @@ -1,4 +1,11 @@ -JP-G,1 -JP-PG12,7 -JP-15+,8 -JP-18+,10 +A,0 +G,0 +B,12 +PG12,12 +C,15 +15+,15 +R15+,15 +16+,16 +D,17 +Z,18 +18+,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/kz.csv b/Emby.Server.Implementations/Localization/Ratings/kz.csv index d546bff53d..e26b32b67e 100644 --- a/Emby.Server.Implementations/Localization/Ratings/kz.csv +++ b/Emby.Server.Implementations/Localization/Ratings/kz.csv @@ -1,7 +1,6 @@ -KZ-6-,0 -KZ-6+,6 -KZ-12+,12 -KZ-14+,14 -KZ-16+,16 -KZ-18+,18 -KZ-21+,21 +K,0 +БА,12 +Б14,14 +E16,16 +E18,18 +HA,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/mx.csv b/Emby.Server.Implementations/Localization/Ratings/mx.csv index 785a8ba227..305912f239 100644 --- a/Emby.Server.Implementations/Localization/Ratings/mx.csv +++ b/Emby.Server.Implementations/Localization/Ratings/mx.csv @@ -1,6 +1,6 @@ -MX-AA,1 -MX-A,5 -MX-B,7 -MX-B-15,8 -MX-C,9 -MX-D,10 +A,0 +AA,0 +B,12 +B-15,15 +C,18 +D,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/nl.csv b/Emby.Server.Implementations/Localization/Ratings/nl.csv index 8c005092e4..44f372b2d6 100644 --- a/Emby.Server.Implementations/Localization/Ratings/nl.csv +++ b/Emby.Server.Implementations/Localization/Ratings/nl.csv @@ -1,6 +1,8 @@ -NL-AL,1 -NL-MG6,2 -NL-6,3 -NL-9,5 -NL-12,6 -NL-16,8 +AL,0 +MG6,6 +6,6 +9,9 +12,12 +14,14 +16,16 +18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/no.csv b/Emby.Server.Implementations/Localization/Ratings/no.csv index 127407be86..c8f8e93db7 100644 --- a/Emby.Server.Implementations/Localization/Ratings/no.csv +++ b/Emby.Server.Implementations/Localization/Ratings/no.csv @@ -1,6 +1,9 @@ -NO-A,1 -NO-6,3 -NO-9,4 -NO-12,5 -NO-15,8 -NO-18,9 +A,0 +6,6 +7,7 +9,9 +11,11 +12,12 +15,15 +18,18 +Not approved,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/nz.csv b/Emby.Server.Implementations/Localization/Ratings/nz.csv index bba99b764a..f617f0c39d 100644 --- a/Emby.Server.Implementations/Localization/Ratings/nz.csv +++ b/Emby.Server.Implementations/Localization/Ratings/nz.csv @@ -1,11 +1,15 @@ -NZ-G,1 -NZ-PG,5 -NZ-M,6 -NZ-R13,7 -NZ-RP13,7 -NZ-R15,8 -NZ-RP16,9 -NZ-R16,9 -NZ-R18,10 -NZ-R,10 -NZ-MA,10 +Exempt,0 +G,0 +GY,13 +PG,13 +R13,13 +RP13,13 +R15,15 +M,16 +R16,16 +RP16,16 +GA,18 +R18,18 +MA,1000 +R,1001 +Objectionable,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/ro.csv b/Emby.Server.Implementations/Localization/Ratings/ro.csv index 4089b282f0..44c23e2486 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ro.csv +++ b/Emby.Server.Implementations/Localization/Ratings/ro.csv @@ -1 +1,6 @@ -RO-AG,1 +AG,0 +AP-12,12 +N-15,15 +IM-18,18 +IM-18-XXX,1000 +IC,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/ru.csv b/Emby.Server.Implementations/Localization/Ratings/ru.csv index 1bc94affd6..8b264070ba 100644 --- a/Emby.Server.Implementations/Localization/Ratings/ru.csv +++ b/Emby.Server.Implementations/Localization/Ratings/ru.csv @@ -1,5 +1,6 @@ -RU-0+,1 -RU-6+,3 -RU-12+,7 -RU-16+,9 -RU-18+,10 +0+,0 +6+,6 +12+,12 +16+,16 +18+,18 +Refused classification,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/se.csv b/Emby.Server.Implementations/Localization/Ratings/se.csv index 1443c07df7..e129c35617 100644 --- a/Emby.Server.Implementations/Localization/Ratings/se.csv +++ b/Emby.Server.Implementations/Localization/Ratings/se.csv @@ -1,5 +1,10 @@ -SE-Btl,1 -SE-Barntillåten,1 -SE-7,3 -SE-11,5 -SE-15,8 +Alla,0 +Barntillåten,0 +Btl,0 +0+,0 +7,7 +9+,9 +10+,10 +11,11 +14,14 +15,15 diff --git a/Emby.Server.Implementations/Localization/Ratings/uk.csv b/Emby.Server.Implementations/Localization/Ratings/uk.csv index 6c8005b3f3..75b1c20589 100644 --- a/Emby.Server.Implementations/Localization/Ratings/uk.csv +++ b/Emby.Server.Implementations/Localization/Ratings/uk.csv @@ -1,7 +1,22 @@ -UK-U,1 -UK-PG,5 -UK-12,7 -UK-12A,7 -UK-15,9 -UK-18,10 -UK-R18,15 +All,0 +E,0 +G,0 +U,0 +0+,0 +6+,6 +7+,7 +PG,8 +9+,9 +12,12 +12+,12 +12A,12 +Teen,13 +13+,13 +14+,14 +15,15 +16,16 +Caution,18 +18,18 +Mature,1000 +Adult,1000 +R18,1000 diff --git a/Emby.Server.Implementations/Localization/Ratings/us.csv b/Emby.Server.Implementations/Localization/Ratings/us.csv index 34c897fe3f..d103ddf42d 100644 --- a/Emby.Server.Implementations/Localization/Ratings/us.csv +++ b/Emby.Server.Implementations/Localization/Ratings/us.csv @@ -1,23 +1,50 @@ -TV-Y,1 -APPROVED,1 -G,1 -E,1 -EC,1 -TV-G,1 -TV-Y7,3 -TV-Y7-FV,4 -PG,5 -TV-PG,5 -PG-13,7 -T,7 -TV-14,8 -R,9 -M,9 -TV-MA,9 -NC-17,10 -AO,15 -RP,15 -UR,15 -NR,15 -X,15 -XXX,100 +Approved,0 +G,0 +TV-G,0 +TV-Y,0 +TV-Y7,7 +TV-Y7-FV,7 +PG,10 +PG-13,13 +TV-PG,13 +TV-PG-D,13 +TV-PG-L,13 +TV-PG-S,13 +TV-PG-V,13 +TV-PG-DL,13 +TV-PG-DS,13 +TV-PG-DV,13 +TV-PG-LS,13 +TV-PG-LV,13 +TV-PG-SV,13 +TV-PG-DLS,13 +TV-PG-DLV,13 +TV-PG-DSV,13 +TV-PG-LSV,13 +TV-PG-DLSV,13 +TV-14,14 +TV-14-D,14 +TV-14-L,14 +TV-14-S,14 +TV-14-V,14 +TV-14-DL,14 +TV-14-DS,14 +TV-14-DV,14 +TV-14-LS,14 +TV-14-LV,14 +TV-14-SV,14 +TV-14-DLS,14 +TV-14-DLV,14 +TV-14-DSV,14 +TV-14-LSV,14 +TV-14-DLSV,14 +NC-17,17 +R,17 +TV-MA,17 +TV-MA-L,17 +TV-MA-S,17 +TV-MA-V,17 +TV-MA-LS,17 +TV-MA-LV,17 +TV-MA-SV,17 +TV-MA-LSV,17 diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index 9fe51f083f..7732e32d0a 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -15,6 +15,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -62,23 +63,16 @@ namespace Emby.Server.Implementations.MediaEncoder /// Determines whether [is eligible for chapter image extraction] [the specified video]. /// </summary> /// <param name="video">The video.</param> + /// <param name="libraryOptions">The library options for the video.</param> /// <returns><c>true</c> if [is eligible for chapter image extraction] [the specified video]; otherwise, <c>false</c>.</returns> - private bool IsEligibleForChapterImageExtraction(Video video) + private bool IsEligibleForChapterImageExtraction(Video video, LibraryOptions libraryOptions) { if (video.IsPlaceHolder) { return false; } - var libraryOptions = _libraryManager.GetLibraryOptions(video); - if (libraryOptions is not null) - { - if (!libraryOptions.EnableChapterImageExtraction) - { - return false; - } - } - else + if (libraryOptions is null || !libraryOptions.EnableChapterImageExtraction) { return false; } @@ -99,7 +93,9 @@ namespace Emby.Server.Implementations.MediaEncoder public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken) { - if (!IsEligibleForChapterImageExtraction(video)) + var libraryOptions = _libraryManager.GetLibraryOptions(video); + + if (!IsEligibleForChapterImageExtraction(video, libraryOptions)) { extractImages = false; } @@ -179,6 +175,12 @@ namespace Emby.Server.Implementations.MediaEncoder chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path); changesMade = true; } + else if (libraryOptions?.EnableChapterImageExtraction != true) + { + // We have an image for the current chapter but the user has disabled chapter image extraction -> delete this chapter's image + chapter.ImagePath = null; + changesMade = true; + } } if (saveChapters && changesMade) diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 2717c392b2..702f8d45bc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -67,9 +67,8 @@ namespace Emby.Server.Implementations.Playlists public async Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options) { var name = options.Name; - var folderName = _fileSystem.GetValidFilename(name); - var parentFolder = GetPlaylistsFolder(Guid.Empty); + var parentFolder = GetPlaylistsFolder(options.UserId); if (parentFolder is null) { throw new ArgumentException(nameof(parentFolder)); @@ -80,7 +79,6 @@ namespace Emby.Server.Implementations.Playlists foreach (var itemId in options.ItemIdList) { var item = _libraryManager.GetItemById(itemId); - if (item is null) { throw new ArgumentException("No item exists with the supplied Id"); @@ -121,7 +119,6 @@ namespace Emby.Server.Implementations.Playlists } var user = _userManager.GetUserById(options.UserId); - var path = Path.Combine(parentFolder.Path, folderName); path = GetTargetPath(path); @@ -130,25 +127,15 @@ namespace Emby.Server.Implementations.Playlists try { Directory.CreateDirectory(path); - var playlist = new Playlist { Name = name, Path = path, - Shares = new[] - { - new Share - { - UserId = options.UserId.Equals(default) - ? null - : options.UserId.ToString("N", CultureInfo.InvariantCulture), - CanEdit = true - } - } + OwnerUserId = options.UserId, + Shares = options.Shares ?? Array.Empty<Share>() }; playlist.SetMediaType(options.MediaType); - parentFolder.AddChild(playlist); await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) @@ -334,7 +321,8 @@ namespace Emby.Server.Implementations.Playlists } } - private void SavePlaylistFile(Playlist item) + /// <inheritdoc /> + public void SavePlaylistFile(Playlist item) { // this is probably best done as a metadata provider // saving a file over itself will require some work to prevent this from happening when not needed @@ -537,5 +525,40 @@ namespace Emby.Server.Implementations.Playlists return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)) ?? _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)); } + + /// <inheritdoc /> + public async Task RemovePlaylistsAsync(Guid userId) + { + var playlists = GetPlaylists(userId); + foreach (var playlist in playlists) + { + // Update owner if shared + var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToArray(); + if (rankedShares.Length > 0 && Guid.TryParse(rankedShares[0].UserId, out var guid)) + { + playlist.OwnerUserId = guid; + playlist.Shares = rankedShares.Skip(1).ToArray(); + await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + + if (playlist.IsFile) + { + SavePlaylistFile(playlist); + } + } + else if (!playlist.OpenAccess) + { + // Remove playlist if not shared + _libraryManager.DeleteItem( + playlist, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + playlist.GetParent(), + false); + } + } + } } } diff --git a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs index e2f2e436f2..d67caa52dc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs @@ -27,11 +27,6 @@ namespace Emby.Server.Implementations.Playlists [JsonIgnore] public override string CollectionType => MediaBrowser.Model.Entities.CollectionType.Playlists; - public override bool IsVisible(User user) - { - return base.IsVisible(user) && GetChildren(user, true).Any(); - } - protected override IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user) { return base.GetEligibleChildrenForRecursiveChildren(user).OfType<Playlist>(); @@ -47,8 +42,7 @@ namespace Emby.Server.Implementations.Playlists query.Recursive = true; query.IncludeItemTypes = new[] { BaseItemKind.Playlist }; - query.Parent = null; - return LibraryManager.GetItemsResult(query); + return QueryWithPostFiltering2(query); } public override string GetClientTypeName() diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index f2212f4dcb..48584ae0cb 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using System.Globalization; using System.IO; using System.Linq; @@ -9,6 +10,8 @@ using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Emby.Server.Implementations.Library; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common; @@ -29,6 +32,8 @@ namespace Emby.Server.Implementations.Plugins /// </summary> public class PluginManager : IPluginManager { + private const string MetafileName = "meta.json"; + private readonly string _pluginsPath; private readonly Version _appVersion; private readonly List<AssemblyLoadContext> _assemblyLoadContexts; @@ -44,7 +49,7 @@ namespace Emby.Server.Implementations.Plugins /// <summary> /// Initializes a new instance of the <see cref="PluginManager"/> class. /// </summary> - /// <param name="logger">The <see cref="ILogger"/>.</param> + /// <param name="logger">The <see cref="ILogger{PluginManager}"/>.</param> /// <param name="appHost">The <see cref="IApplicationHost"/>.</param> /// <param name="config">The <see cref="ServerConfiguration"/>.</param> /// <param name="pluginsPath">The plugin path.</param> @@ -123,41 +128,64 @@ namespace Emby.Server.Implementations.Plugins continue; } + var assemblyLoadContext = new PluginLoadContext(plugin.Path); + _assemblyLoadContexts.Add(assemblyLoadContext); + + var assemblies = new List<Assembly>(plugin.DllFiles.Count); + var loadedAll = true; + foreach (var file in plugin.DllFiles) { - Assembly assembly; try { - var assemblyLoadContext = new PluginLoadContext(file); - _assemblyLoadContexts.Add(assemblyLoadContext); - - assembly = assemblyLoadContext.LoadFromAssemblyPath(file); - - // Load all required types to verify that the plugin will load - assembly.GetTypes(); + assemblies.Add(assemblyLoadContext.LoadFromAssemblyPath(file)); } catch (FileLoadException ex) { - _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file); + _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin", file); ChangePluginState(plugin, PluginStatus.Malfunctioned); - continue; + loadedAll = false; + break; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin", file); + ChangePluginState(plugin, PluginStatus.Malfunctioned); + loadedAll = false; + break; + } + } + + if (!loadedAll) + { + continue; + } + + foreach (var assembly in assemblies) + { + try + { + // Load all required types to verify that the plugin will load + assembly.GetTypes(); } catch (SystemException ex) when (ex is TypeLoadException or ReflectionTypeLoadException) // Undocumented exception { - _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references an incompatible version of one of the shared libraries. Disabling plugin.", file); + _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references an incompatible version of one of the shared libraries. Disabling plugin", assembly.Location); ChangePluginState(plugin, PluginStatus.NotSupported); - continue; + break; } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception ex) #pragma warning restore CA1031 // Do not catch general exception types { - _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin.", file); + _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin", assembly.Location); ChangePluginState(plugin, PluginStatus.Malfunctioned); - continue; + break; } - _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, file); + _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, assembly.Location); yield return assembly; } } @@ -281,7 +309,7 @@ namespace Emby.Server.Implementations.Plugins // If no version is given, return the current instance. var plugins = _plugins.Where(p => p.Id.Equals(id)).ToList(); - plugin = plugins.FirstOrDefault(p => p.Instance is not null) ?? plugins.OrderByDescending(p => p.Version).FirstOrDefault(); + plugin = plugins.FirstOrDefault(p => p.Instance is not null) ?? plugins.MaxBy(p => p.Version); } else { @@ -348,7 +376,7 @@ namespace Emby.Server.Implementations.Plugins try { var data = JsonSerializer.Serialize(manifest, _jsonOptions); - File.WriteAllText(Path.Combine(path, "meta.json"), data); + File.WriteAllText(Path.Combine(path, MetafileName), data); return true; } catch (ArgumentException e) @@ -359,7 +387,7 @@ namespace Emby.Server.Implementations.Plugins } /// <inheritdoc/> - public async Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) + public async Task<bool> PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) { var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); var imagePath = string.Empty; @@ -404,10 +432,72 @@ namespace Emby.Server.Implementations.Plugins ImagePath = imagePath }; + if (!await ReconcileManifest(manifest, path)) + { + // An error occurred during reconciliation and saving could be undesirable. + return false; + } + return SaveManifest(manifest, path); } /// <summary> + /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. + /// If no file is found, no reconciliation occurs. + /// </summary> + /// <param name="manifest">The <see cref="PluginManifest"/> to reconcile against.</param> + /// <param name="path">The plugin path.</param> + /// <returns>The reconciled <see cref="PluginManifest"/>.</returns> + private async Task<bool> ReconcileManifest(PluginManifest manifest, string path) + { + try + { + var metafile = Path.Combine(path, MetafileName); + if (!File.Exists(metafile)) + { + _logger.LogInformation("No local manifest exists for plugin {Plugin}. Skipping manifest reconciliation.", manifest.Name); + return true; + } + + using var metaStream = File.OpenRead(metafile); + var localManifest = await JsonSerializer.DeserializeAsync<PluginManifest>(metaStream, _jsonOptions); + localManifest ??= new PluginManifest(); + + if (!Equals(localManifest.Id, manifest.Id)) + { + _logger.LogError("The manifest ID {LocalUUID} did not match the package info ID {PackageUUID}.", localManifest.Id, manifest.Id); + manifest.Status = PluginStatus.Malfunctioned; + } + + if (localManifest.Version != manifest.Version) + { + // Package information provides the version and is the source of truth. Pre-packages meta.json is assumed to be a mistake in this regard. + _logger.LogWarning("The version of the local manifest was {LocalVersion}, but {PackageVersion} was expected. The value will be replaced.", localManifest.Version, manifest.Version); + } + + // Explicitly mapping properties instead of using reflection is preferred here. + manifest.Category = string.IsNullOrEmpty(localManifest.Category) ? manifest.Category : localManifest.Category; + manifest.AutoUpdate = localManifest.AutoUpdate; // Preserve whatever is local. Package info does not have this property. + manifest.Changelog = string.IsNullOrEmpty(localManifest.Changelog) ? manifest.Changelog : localManifest.Changelog; + manifest.Description = string.IsNullOrEmpty(localManifest.Description) ? manifest.Description : localManifest.Description; + manifest.Name = string.IsNullOrEmpty(localManifest.Name) ? manifest.Name : localManifest.Name; + manifest.Overview = string.IsNullOrEmpty(localManifest.Overview) ? manifest.Overview : localManifest.Overview; + manifest.Owner = string.IsNullOrEmpty(localManifest.Owner) ? manifest.Owner : localManifest.Owner; + manifest.TargetAbi = string.IsNullOrEmpty(localManifest.TargetAbi) ? manifest.TargetAbi : localManifest.TargetAbi; + manifest.Timestamp = localManifest.Timestamp.Equals(default) ? manifest.Timestamp : localManifest.Timestamp; + manifest.ImagePath = string.IsNullOrEmpty(localManifest.ImagePath) ? manifest.ImagePath : localManifest.ImagePath; + manifest.Assemblies = localManifest.Assemblies; + + return true; + } + catch (Exception e) + { + _logger.LogWarning(e, "Unable to reconcile plugin manifest due to an error. {Path}", path); + return false; + } + } + + /// <summary> /// Changes a plugin's load status. /// </summary> /// <param name="plugin">The <see cref="LocalPlugin"/> instance.</param> @@ -571,7 +661,7 @@ namespace Emby.Server.Implementations.Plugins { Version? version; PluginManifest? manifest = null; - var metafile = Path.Combine(dir, "meta.json"); + var metafile = Path.Combine(dir, MetafileName); if (File.Exists(metafile)) { // Only path where this stays null is when File.ReadAllBytes throws an IOException @@ -665,7 +755,15 @@ namespace Emby.Server.Implementations.Plugins var entry = versions[x]; if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) { - entry.DllFiles = Directory.GetFiles(entry.Path, "*.dll", SearchOption.AllDirectories); + if (!TryGetPluginDlls(entry, out var allowedDlls)) + { + _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name); + ChangePluginState(entry, PluginStatus.Malfunctioned); + continue; + } + + entry.DllFiles = allowedDlls; + if (entry.IsEnabledAndSupported) { lastName = entry.Name; @@ -712,6 +810,68 @@ namespace Emby.Server.Implementations.Plugins } /// <summary> + /// Attempts to retrieve valid DLLs from the plugin path. This method will consider the assembly whitelist + /// from the manifest. + /// </summary> + /// <remarks> + /// Loading DLLs from externally supplied paths introduces a path traversal risk. This method + /// uses a safelisting tactic of considering DLLs from the plugin directory and only using + /// the plugin's canonicalized assembly whitelist for comparison. See + /// <see href="https://owasp.org/www-community/attacks/Path_Traversal"/> for more details. + /// </remarks> + /// <param name="plugin">The plugin.</param> + /// <param name="whitelistedDlls">The whitelisted DLLs. If the method returns <see langword="false"/>, this will be empty.</param> + /// <returns> + /// <see langword="true"/> if all assemblies listed in the manifest were available in the plugin directory. + /// <see langword="false"/> if any assemblies were invalid or missing from the plugin directory. + /// </returns> + /// <exception cref="ArgumentNullException">If the <see cref="LocalPlugin"/> is null.</exception> + private bool TryGetPluginDlls(LocalPlugin plugin, out IReadOnlyList<string> whitelistedDlls) + { + ArgumentNullException.ThrowIfNull(nameof(plugin)); + + IReadOnlyList<string> pluginDlls = Directory.GetFiles(plugin.Path, "*.dll", SearchOption.AllDirectories); + + whitelistedDlls = Array.Empty<string>(); + if (pluginDlls.Count > 0 && plugin.Manifest.Assemblies.Count > 0) + { + _logger.LogInformation("Registering whitelisted assemblies for plugin \"{Plugin}\"...", plugin.Name); + + var canonicalizedPaths = new List<string>(); + foreach (var path in plugin.Manifest.Assemblies) + { + var canonicalized = Path.Combine(plugin.Path, path).Canonicalize(); + + // Ensure we stay in the plugin directory. + if (!canonicalized.StartsWith(plugin.Path.NormalizePath(), StringComparison.Ordinal)) + { + _logger.LogError("Assembly path {Path} is not inside the plugin directory.", path); + return false; + } + + canonicalizedPaths.Add(canonicalized); + } + + var intersected = pluginDlls.Intersect(canonicalizedPaths).ToList(); + + if (intersected.Count != canonicalizedPaths.Count) + { + _logger.LogError("Plugin {Plugin} contained assembly paths that were not found in the directory.", plugin.Name); + return false; + } + + whitelistedDlls = intersected; + } + else + { + // No whitelist, default to loading all DLLs in plugin directory. + whitelistedDlls = pluginDlls; + } + + return true; + } + + /// <summary> /// Changes the status of the other versions of the plugin to "Superceded". /// </summary> /// <param name="plugin">The <see cref="LocalPlugin"/> that's master.</param> diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index ee9aa85699..1af2c96d2f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -93,11 +93,8 @@ namespace Emby.Server.Implementations.ScheduledTasks public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger) { ArgumentNullException.ThrowIfNull(scheduledTask); - ArgumentNullException.ThrowIfNull(applicationPaths); - ArgumentNullException.ThrowIfNull(taskManager); - ArgumentNullException.ThrowIfNull(logger); ScheduledTask = scheduledTask; @@ -332,7 +329,7 @@ namespace Emby.Server.Implementations.ScheduledTasks return; } - _logger.LogInformation("{0} fired for task: {1}", trigger.GetType().Name, Name); + _logger.LogDebug("{0} fired for task: {1}", trigger.GetType().Name, Name); trigger.Stop(); @@ -378,7 +375,7 @@ namespace Emby.Server.Implementations.ScheduledTasks CurrentCancellationTokenSource = new CancellationTokenSource(); - _logger.LogInformation("Executing {0}", Name); + _logger.LogDebug("Executing {0}", Name); ((TaskManager)_taskManager).OnTaskExecuting(this); @@ -406,7 +403,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } catch (Exception ex) { - _logger.LogError(ex, "Error"); + _logger.LogError(ex, "Error executing Scheduled Task"); failureException = ex; diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 63f0beb105..42c30c959d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -43,9 +41,9 @@ namespace Emby.Server.Implementations.ScheduledTasks ScheduledTasks = Array.Empty<IScheduledTaskWorker>(); } - public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + public event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting; - public event EventHandler<TaskCompletionEventArgs> TaskCompleted; + public event EventHandler<TaskCompletionEventArgs>? TaskCompleted; /// <summary> /// Gets the list of Scheduled Tasks. @@ -134,7 +132,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = scheduledTask.ScheduledTask.GetType(); - _logger.LogInformation("Queuing task {0}", type.Name); + _logger.LogDebug("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -174,7 +172,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = task.ScheduledTask.GetType(); - _logger.LogInformation("Queuing task {0}", type.Name); + _logger.LogDebug("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -256,9 +254,6 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void ExecuteQueuedTasks() { - _logger.LogInformation("ExecuteQueuedTasks"); - - // Execute queued tasks lock (_taskQueue) { var list = new List<Tuple<Type, TaskOptions>>(); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index abc203618d..6ad6c4cbd6 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -100,7 +100,6 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks EnableImages = false }, SourceTypes = new SourceType[] { SourceType.Library }, - HasChapterImages = false, IsVirtualItem = false }) .OfType<Video>() diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionPathsTask.cs new file mode 100644 index 0000000000..f78fc6f970 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionPathsTask.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks; + +/// <summary> +/// Deletes Path references from collections that no longer exists. +/// </summary> +public class CleanupCollectionPathsTask : IScheduledTask +{ + private readonly ILocalizationManager _localization; + private readonly ICollectionManager _collectionManager; + private readonly ILogger<CleanupCollectionPathsTask> _logger; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanupCollectionPathsTask"/> class. + /// </summary> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param> + /// <param name="logger">The logger.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="fileSystem">The filesystem.</param> + public CleanupCollectionPathsTask( + ILocalizationManager localization, + ICollectionManager collectionManager, + ILogger<CleanupCollectionPathsTask> logger, + IProviderManager providerManager, + IFileSystem fileSystem) + { + _localization = localization; + _collectionManager = collectionManager; + _logger = logger; + _providerManager = providerManager; + _fileSystem = fileSystem; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanCollections"); + + /// <inheritdoc /> + public string Key => "CleanCollections"; + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskCleanCollectionsDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var collectionsFolder = await _collectionManager.GetCollectionsFolder(false).ConfigureAwait(false); + if (collectionsFolder is null) + { + _logger.LogDebug("There is no collection folder to be found"); + return; + } + + var collections = collectionsFolder.Children.OfType<BoxSet>().ToArray(); + _logger.LogDebug("Found {CollectionLength} Boxsets", collections.Length); + + var itemsToRemove = new List<LinkedChild>(); + for (var index = 0; index < collections.Length; index++) + { + var collection = collections[index]; + _logger.LogDebug("Check Boxset {CollectionName}", collection.Name); + + foreach (var collectionLinkedChild in collection.LinkedChildren) + { + if (!File.Exists(collectionLinkedChild.Path)) + { + _logger.LogInformation("Item in boxset {CollectionName} cannot be found at {ItemPath}", collection.Name, collectionLinkedChild.Path); + itemsToRemove.Add(collectionLinkedChild); + } + } + + if (itemsToRemove.Count != 0) + { + _logger.LogDebug("Update Boxset {CollectionName}", collection.Name); + collection.LinkedChildren = collection.LinkedChildren.Except(itemsToRemove).ToArray(); + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken) + .ConfigureAwait(false); + + _providerManager.QueueRefresh( + collection.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + RefreshPriority.High); + + itemsToRemove.Clear(); + } + + progress.Report(100D / collections.Length * (index + 1)); + } + } + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] { new TaskTriggerInfo() { Type = TaskTriggerInfo.TriggerStartup } }; + } +} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index afa3721b88..5f6dc93fb3 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -606,7 +606,7 @@ namespace Emby.Server.Implementations.Session } catch (Exception ex) { - _logger.LogDebug("Error calling OnPlaybackStopped", ex); + _logger.LogDebug(ex, "Error calling OnPlaybackStopped"); } } @@ -953,7 +953,7 @@ namespace Emby.Server.Implementations.Session } catch (Exception ex) { - _logger.LogError("Error closing live stream", ex); + _logger.LogError(ex, "Error closing live stream"); } } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index aebb559073..4e427b1a4b 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -58,7 +56,7 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The KeepAlive cancellation token. /// </summary> - private CancellationTokenSource _keepAliveCancellationToken; + private CancellationTokenSource? _keepAliveCancellationToken; /// <summary> /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class. @@ -105,7 +103,7 @@ namespace Emby.Server.Implementations.Session } } - private async Task<SessionInfo> GetSession(HttpContext httpContext, string remoteEndpoint) + private async Task<SessionInfo?> GetSession(HttpContext httpContext, string? remoteEndpoint) { if (!httpContext.User.Identity?.IsAuthenticated ?? false) { @@ -138,8 +136,13 @@ namespace Emby.Server.Implementations.Session /// </summary> /// <param name="sender">The WebSocket.</param> /// <param name="e">The event arguments.</param> - private void OnWebSocketClosed(object sender, EventArgs e) + private void OnWebSocketClosed(object? sender, EventArgs e) { + if (sender is null) + { + return; + } + var webSocket = (IWebSocketConnection)sender; _logger.LogDebug("WebSocket {0} is closed.", webSocket); RemoveWebSocket(webSocket); diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs index 051fa5b3cf..cf8e0fb006 100644 --- a/Emby.Server.Implementations/Session/WebSocketController.cs +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -7,8 +7,8 @@ using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Net; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; @@ -69,9 +69,7 @@ namespace Emby.Server.Implementations.Session T data, CancellationToken cancellationToken) { - var socket = GetActiveSockets() - .OrderByDescending(i => i.LastActivityDate) - .FirstOrDefault(); + var socket = GetActiveSockets().MaxBy(i => i.LastActivityDate); if (socket is null) { @@ -79,7 +77,7 @@ namespace Emby.Server.Implementations.Session } return socket.SendAsync( - new WebSocketMessage<T> + new OutboundWebSocketMessage<T> { Data = data, MessageType = name, diff --git a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs index 646bafbb54..753e58324c 100644 --- a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs +++ b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; @@ -24,10 +22,9 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { ArgumentNullException.ThrowIfNull(x); - ArgumentNullException.ThrowIfNull(y); return (x.RunTimeTicks ?? 0).CompareTo(y.RunTimeTicks ?? 0); diff --git a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs index 0bd9600b98..5b6c64f63a 100644 --- a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -23,15 +21,14 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.OrdinalIgnoreCase); } - private static string GetValue(BaseItem item) + private static string? GetValue(BaseItem? item) { var hasSeries = item as IHasSeries; - return hasSeries?.FindSeriesSortName(); } } diff --git a/Emby.Server.Implementations/Sorting/SortNameComparer.cs b/Emby.Server.Implementations/Sorting/SortNameComparer.cs index 628b9b3dda..19abafe192 100644 --- a/Emby.Server.Implementations/Sorting/SortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SortNameComparer.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; @@ -24,10 +22,9 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { ArgumentNullException.ThrowIfNull(x); - ArgumentNullException.ThrowIfNull(y); return string.Compare(x.SortName, y.SortName, StringComparison.OrdinalIgnoreCase); diff --git a/Emby.Server.Implementations/Sorting/StartDateComparer.cs b/Emby.Server.Implementations/Sorting/StartDateComparer.cs index c3df7c47e6..2759d20de8 100644 --- a/Emby.Server.Implementations/Sorting/StartDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/StartDateComparer.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -24,7 +22,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetDate(x).CompareTo(GetDate(y)); } @@ -34,7 +32,7 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>DateTime.</returns> - private static DateTime GetDate(BaseItem x) + private static DateTime GetDate(BaseItem? x) { if (x is LiveTvProgram hasStartDate) { diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 457c062714..89d10f3d23 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -24,10 +22,9 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { ArgumentNullException.ThrowIfNull(x); - ArgumentNullException.ThrowIfNull(y); return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index 7d7ea58106..da8f949326 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -620,10 +620,8 @@ namespace Emby.Server.Implementations.SyncPlay RestartCurrentItem(); return true; } - else - { - return false; - } + + return false; } /// <inheritdoc /> @@ -637,10 +635,8 @@ namespace Emby.Server.Implementations.SyncPlay RestartCurrentItem(); return true; } - else - { - return false; - } + + return false; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 63c4a15564..00c655634a 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -339,10 +339,8 @@ namespace Emby.Server.Implementations.SyncPlay { return sessionsCounter > 0; } - else - { - return false; - } + + return false; } /// <summary> diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 967f90b55f..f0e173f0b1 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -42,7 +40,7 @@ namespace Emby.Server.Implementations.TV throw new ArgumentException("User not found"); } - string presentationUniqueKey = null; + string? presentationUniqueKey = null; if (query.SeriesId.HasValue && !query.SeriesId.Value.Equals(default)) { if (_libraryManager.GetItemById(query.SeriesId.Value) is Series series) @@ -91,7 +89,7 @@ namespace Emby.Server.Implementations.TV throw new ArgumentException("User not found"); } - string presentationUniqueKey = null; + string? presentationUniqueKey = null; int? limit = null; if (request.SeriesId.HasValue && !request.SeriesId.Value.Equals(default)) { @@ -168,7 +166,7 @@ namespace Emby.Server.Implementations.TV return !anyFound && i.LastWatchedDate == DateTime.MinValue; }) .Select(i => i.GetEpisodeFunction()) - .Where(i => i is not null); + .Where(i => i is not null)!; } private static string GetUniqueSeriesKey(Episode episode) @@ -185,7 +183,7 @@ namespace Emby.Server.Implementations.TV /// Gets the next up. /// </summary> /// <returns>Task{Episode}.</returns> - private (DateTime LastWatchedDate, Func<Episode> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool rewatching) + private (DateTime LastWatchedDate, Func<Episode?> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool rewatching) { var lastQuery = new InternalItemsQuery(user) { @@ -209,7 +207,7 @@ namespace Emby.Server.Implementations.TV var lastWatchedEpisode = _libraryManager.GetItemList(lastQuery).Cast<Episode>().FirstOrDefault(); - Episode GetEpisode() + Episode? GetEpisode() { var nextQuery = new InternalItemsQuery(user) { diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 5e897833e0..6c198b6f99 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -183,7 +183,7 @@ namespace Emby.Server.Implementations.Updates var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber); if (plugin is not null) { - await _pluginManager.GenerateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); + await _pluginManager.PopulateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); } // Remove versions with a target ABI greater then the current application version. @@ -555,7 +555,10 @@ namespace Emby.Server.Implementations.Updates stream.Position = 0; using var reader = new ZipArchive(stream); reader.ExtractToDirectory(targetDir, true); - await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); + + // Ensure we create one or populate existing ones with missing data. + await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status); + _pluginManager.ImportPluginFrom(targetDir); } diff --git a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs index fbe68b6b97..a6c89bab88 100644 --- a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs @@ -2,29 +2,28 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Internal produces image attribute. +/// </summary> +[AttributeUsage(AttributeTargets.Method)] +public class AcceptsFileAttribute : Attribute { + private readonly string[] _contentTypes; + /// <summary> - /// Internal produces image attribute. + /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class. /// </summary> - [AttributeUsage(AttributeTargets.Method)] - public class AcceptsFileAttribute : Attribute + /// <param name="contentTypes">Content types this endpoint produces.</param> + public AcceptsFileAttribute(params string[] contentTypes) { - private readonly string[] _contentTypes; - - /// <summary> - /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class. - /// </summary> - /// <param name="contentTypes">Content types this endpoint produces.</param> - public AcceptsFileAttribute(params string[] contentTypes) - { - _contentTypes = contentTypes; - } - - /// <summary> - /// Gets the configured content types. - /// </summary> - /// <returns>the configured content types.</returns> - public string[] ContentTypes => _contentTypes; + _contentTypes = contentTypes; } + + /// <summary> + /// Gets the configured content types. + /// </summary> + /// <returns>the configured content types.</returns> + public string[] ContentTypes => _contentTypes; } diff --git a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs index 244a29da45..57433202e1 100644 --- a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute { + private const string ContentType = "image/*"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class. /// </summary> - public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute + public AcceptsImageFileAttribute() + : base(ContentType) { - private const string ContentType = "image/*"; - - /// <summary> - /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class. - /// </summary> - public AcceptsImageFileAttribute() - : base(ContentType) - { - } } } diff --git a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs index 4dcf5976a2..cbd32ed822 100644 --- a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs @@ -2,29 +2,28 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Routing; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Identifies an action that supports the HTTP GET method. +/// </summary> +public sealed class HttpSubscribeAttribute : HttpMethodAttribute { + private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" }; + /// <summary> - /// Identifies an action that supports the HTTP GET method. + /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. /// </summary> - public sealed class HttpSubscribeAttribute : HttpMethodAttribute + public HttpSubscribeAttribute() + : base(_supportedMethods) { - private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" }; - - /// <summary> - /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. - /// </summary> - public HttpSubscribeAttribute() - : base(_supportedMethods) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. - /// </summary> - /// <param name="template">The route template. May not be null.</param> - public HttpSubscribeAttribute(string template) - : base(_supportedMethods, template) - => ArgumentNullException.ThrowIfNull(template); } + + /// <summary> + /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. + /// </summary> + /// <param name="template">The route template. May not be null.</param> + public HttpSubscribeAttribute(string template) + : base(_supportedMethods, template) + => ArgumentNullException.ThrowIfNull(template); } diff --git a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs index d0238424a7..f4a6dcdaf9 100644 --- a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs @@ -2,29 +2,28 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Routing; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Identifies an action that supports the HTTP GET method. +/// </summary> +public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute { + private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" }; + /// <summary> - /// Identifies an action that supports the HTTP GET method. + /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. /// </summary> - public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute + public HttpUnsubscribeAttribute() + : base(_supportedMethods) { - private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" }; - - /// <summary> - /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. - /// </summary> - public HttpUnsubscribeAttribute() - : base(_supportedMethods) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. - /// </summary> - /// <param name="template">The route template. May not be null.</param> - public HttpUnsubscribeAttribute(string template) - : base(_supportedMethods, template) - => ArgumentNullException.ThrowIfNull(template); } + + /// <summary> + /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. + /// </summary> + /// <param name="template">The route template. May not be null.</param> + public HttpUnsubscribeAttribute(string template) + : base(_supportedMethods, template) + => ArgumentNullException.ThrowIfNull(template); } diff --git a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs index 514e7ce974..bf64fef5d7 100644 --- a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs +++ b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs @@ -1,12 +1,11 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Attribute to mark a parameter as obsolete. +/// </summary> +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class ParameterObsoleteAttribute : Attribute { - /// <summary> - /// Attribute to mark a parameter as obsolete. - /// </summary> - [AttributeUsage(AttributeTargets.Parameter)] - public sealed class ParameterObsoleteAttribute : Attribute - { - } } diff --git a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs index 9fc25f192e..7ce09c299d 100644 --- a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class ProducesAudioFileAttribute : ProducesFileAttribute { + private const string ContentType = "audio/*"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class. /// </summary> - public sealed class ProducesAudioFileAttribute : ProducesFileAttribute + public ProducesAudioFileAttribute() + : base(ContentType) { - private const string ContentType = "audio/*"; - - /// <summary> - /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class. - /// </summary> - public ProducesAudioFileAttribute() - : base(ContentType) - { - } } } diff --git a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs index d8e4141acb..c728f68e07 100644 --- a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs @@ -2,29 +2,28 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Internal produces image attribute. +/// </summary> +[AttributeUsage(AttributeTargets.Method)] +public class ProducesFileAttribute : Attribute { + private readonly string[] _contentTypes; + /// <summary> - /// Internal produces image attribute. + /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class. /// </summary> - [AttributeUsage(AttributeTargets.Method)] - public class ProducesFileAttribute : Attribute + /// <param name="contentTypes">Content types this endpoint produces.</param> + public ProducesFileAttribute(params string[] contentTypes) { - private readonly string[] _contentTypes; - - /// <summary> - /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class. - /// </summary> - /// <param name="contentTypes">Content types this endpoint produces.</param> - public ProducesFileAttribute(params string[] contentTypes) - { - _contentTypes = contentTypes; - } - - /// <summary> - /// Gets the configured content types. - /// </summary> - /// <returns>the configured content types.</returns> - public string[] ContentTypes => _contentTypes; + _contentTypes = contentTypes; } + + /// <summary> + /// Gets the configured content types. + /// </summary> + /// <returns>the configured content types.</returns> + public string[] ContentTypes => _contentTypes; } diff --git a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs index 1e5b542e27..f145a061ee 100644 --- a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class ProducesImageFileAttribute : ProducesFileAttribute { + private const string ContentType = "image/*"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class. /// </summary> - public sealed class ProducesImageFileAttribute : ProducesFileAttribute + public ProducesImageFileAttribute() + : base(ContentType) { - private const string ContentType = "image/*"; - - /// <summary> - /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class. - /// </summary> - public ProducesImageFileAttribute() - : base(ContentType) - { - } } } diff --git a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs index 5b15cb1a56..c03ed740c0 100644 --- a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute { + private const string ContentType = "application/x-mpegURL"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class. /// </summary> - public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute + public ProducesPlaylistFileAttribute() + : base(ContentType) { - private const string ContentType = "application/x-mpegURL"; - - /// <summary> - /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class. - /// </summary> - public ProducesPlaylistFileAttribute() - : base(ContentType) - { - } } } diff --git a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs index 6857d45ecc..10dec0c00e 100644 --- a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "video/*". +/// </summary> +public sealed class ProducesVideoFileAttribute : ProducesFileAttribute { + private const string ContentType = "video/*"; + /// <summary> - /// Produces file attribute of "video/*". + /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class. /// </summary> - public sealed class ProducesVideoFileAttribute : ProducesFileAttribute + public ProducesVideoFileAttribute() + : base(ContentType) { - private const string ContentType = "video/*"; - - /// <summary> - /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class. - /// </summary> - public ProducesVideoFileAttribute() - : base(ContentType) - { - } } } diff --git a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs index d4b1ffb060..741b88ea95 100644 --- a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs +++ b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -29,7 +30,7 @@ namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AnonymousLanAccessRequirement requirement) { - var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress; + var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp(); // Loopback will be on LAN, so we can accept null. if (ip is null || _networkManager.IsInLocalNetwork(ip)) diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs deleted file mode 100644 index 8e5e66d64a..0000000000 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Security.Claims; -using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth -{ - /// <summary> - /// Base authorization handler. - /// </summary> - /// <typeparam name="T">Type of Authorization Requirement.</typeparam> - public abstract class BaseAuthorizationHandler<T> : AuthorizationHandler<T> - where T : IAuthorizationRequirement - { - private readonly IUserManager _userManager; - private readonly INetworkManager _networkManager; - private readonly IHttpContextAccessor _httpContextAccessor; - - /// <summary> - /// Initializes a new instance of the <see cref="BaseAuthorizationHandler{T}"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - protected BaseAuthorizationHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - { - _userManager = userManager; - _networkManager = networkManager; - _httpContextAccessor = httpContextAccessor; - } - - /// <summary> - /// Validate authenticated claims. - /// </summary> - /// <param name="claimsPrincipal">Request claims.</param> - /// <param name="ignoreSchedule">Whether to ignore parental control.</param> - /// <param name="localAccessOnly">Whether access is to be allowed locally only.</param> - /// <param name="requiredDownloadPermission">Whether validation requires download permission.</param> - /// <returns>Validated claim status.</returns> - protected bool ValidateClaims( - ClaimsPrincipal claimsPrincipal, - bool ignoreSchedule = false, - bool localAccessOnly = false, - bool requiredDownloadPermission = false) - { - // ApiKey is currently global admin, always allow. - var isApiKey = claimsPrincipal.GetIsApiKey(); - if (isApiKey) - { - return true; - } - - // Ensure claim has userId. - var userId = claimsPrincipal.GetUserId(); - if (userId.Equals(default)) - { - return false; - } - - // Ensure userId links to a valid user. - var user = _userManager.GetUserById(userId); - if (user is null) - { - return false; - } - - // Ensure user is not disabled. - if (user.HasPermission(PermissionKind.IsDisabled)) - { - return false; - } - - var isInLocalNetwork = _httpContextAccessor.HttpContext is not null - && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIp()); - - // User cannot access remotely and user is remote - if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork) - { - return false; - } - - if (localAccessOnly && !isInLocalNetwork) - { - return false; - } - - // User attempting to access out of parental control hours. - if (!ignoreSchedule - && !user.HasPermission(PermissionKind.IsAdministrator) - && !user.IsParentalScheduleAllowed()) - { - return false; - } - - // User attempting to download without permission. - if (requiredDownloadPermission - && !user.HasPermission(PermissionKind.EnableContentDownloading)) - { - return false; - } - - return true; - } - } -} diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index be77b7a4e4..de271ab640 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -1,4 +1,8 @@ using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -9,8 +13,12 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy /// <summary> /// Default authorization handler. /// </summary> - public class DefaultAuthorizationHandler : BaseAuthorizationHandler<DefaultAuthorizationRequirement> + public class DefaultAuthorizationHandler : AuthorizationHandler<DefaultAuthorizationRequirement> { + private readonly IUserManager _userManager; + private readonly INetworkManager _networkManager; + private readonly IHttpContextAccessor _httpContextAccessor; + /// <summary> /// Initializes a new instance of the <see cref="DefaultAuthorizationHandler"/> class. /// </summary> @@ -21,21 +29,63 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy IUserManager userManager, INetworkManager networkManager, IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) { + _userManager = userManager; + _networkManager = networkManager; + _httpContextAccessor = httpContextAccessor; } /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement) { - var validated = ValidateClaims(context.User); - if (validated) + var isApiKey = context.User.GetIsApiKey(); + var userId = context.User.GetUserId(); + // This likely only happens during the wizard, so skip the default checks and let any other handlers do it + if (!isApiKey && userId.Equals(default)) + { + return Task.CompletedTask; + } + + if (isApiKey) + { + // Api keys are unrestricted. + context.Succeed(requirement); + return Task.CompletedTask; + } + + var isInLocalNetwork = _httpContextAccessor.HttpContext is not null + && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIp()); + var user = _userManager.GetUserById(userId); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + // User cannot access remotely and user is remote + if (!isInLocalNetwork && !user.HasPermission(PermissionKind.EnableRemoteAccess)) + { + context.Fail(); + return Task.CompletedTask; + } + + // Admins can do everything + if (context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(requirement); + return Task.CompletedTask; } - else + + // It's not great to have this check, but parental schedule must usually be honored except in a few rare cases + if (requirement.ValidateParentalSchedule && !user.IsParentalScheduleAllowed()) { context.Fail(); + return Task.CompletedTask; + } + + // Only succeed if the requirement isn't a subclass as any subclassed requirement will handle success in its own handler + if (requirement.GetType() == typeof(DefaultAuthorizationRequirement)) + { + context.Succeed(requirement); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs index 7cea00b694..5ba1bc330d 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs @@ -7,5 +7,18 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy /// </summary> public class DefaultAuthorizationRequirement : IAuthorizationRequirement { + /// <summary> + /// Initializes a new instance of the <see cref="DefaultAuthorizationRequirement"/> class. + /// </summary> + /// <param name="validateParentalSchedule">A value indicating whether to validate parental schedule.</param> + public DefaultAuthorizationRequirement(bool validateParentalSchedule = true) + { + ValidateParentalSchedule = validateParentalSchedule; + } + + /// <summary> + /// Gets a value indicating whether to ignore parental schedule. + /// </summary> + public bool ValidateParentalSchedule { get; } } } diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs deleted file mode 100644 index b61680ab1a..0000000000 --- a/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.DownloadPolicy -{ - /// <summary> - /// Download authorization handler. - /// </summary> - public class DownloadHandler : BaseAuthorizationHandler<DownloadRequirement> - { - /// <summary> - /// Initializes a new instance of the <see cref="DownloadHandler"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - public DownloadHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DownloadRequirement requirement) - { - var validated = ValidateClaims(context.User); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs deleted file mode 100644 index b0a72a9dec..0000000000 --- a/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.DownloadPolicy -{ - /// <summary> - /// The download permission requirement. - /// </summary> - public class DownloadRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs deleted file mode 100644 index 31482a930f..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy -{ - /// <summary> - /// Ignore parental control schedule and allow before startup wizard has been completed. - /// </summary> - public class FirstTimeOrIgnoreParentalControlSetupHandler : BaseAuthorizationHandler<FirstTimeOrIgnoreParentalControlSetupRequirement> - { - private readonly IConfigurationManager _configurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="FirstTimeOrIgnoreParentalControlSetupHandler"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> - public FirstTimeOrIgnoreParentalControlSetupHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor, - IConfigurationManager configurationManager) - : base(userManager, networkManager, httpContextAccessor) - { - _configurationManager = configurationManager; - } - - /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeOrIgnoreParentalControlSetupRequirement requirement) - { - if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) - { - context.Succeed(requirement); - return Task.CompletedTask; - } - - var validated = ValidateClaims(context.User, ignoreSchedule: true); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs deleted file mode 100644 index 00aaec334b..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy -{ - /// <summary> - /// First time setup or ignore parental controls requirement. - /// </summary> - public class FirstTimeOrIgnoreParentalControlSetupRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs deleted file mode 100644 index dd0bd4ec2f..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy -{ - /// <summary> - /// Authorization handler for requiring first time setup or default privileges. - /// </summary> - public class FirstTimeSetupOrDefaultHandler : BaseAuthorizationHandler<FirstTimeSetupOrDefaultRequirement> - { - private readonly IConfigurationManager _configurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="FirstTimeSetupOrDefaultHandler" /> class. - /// </summary> - /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - public FirstTimeSetupOrDefaultHandler( - IConfigurationManager configurationManager, - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - _configurationManager = configurationManager; - } - - /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrDefaultRequirement requirement) - { - if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) - { - context.Succeed(requirement); - return Task.CompletedTask; - } - - var validated = ValidateClaims(context.User); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs deleted file mode 100644 index f7366bd7a9..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy -{ - /// <summary> - /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler. - /// </summary> - public class FirstTimeSetupOrDefaultRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs deleted file mode 100644 index 51ba637b60..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy -{ - /// <summary> - /// The authorization requirement, requiring incomplete first time setup or elevated privileges, for the authorization handler. - /// </summary> - public class FirstTimeSetupOrElevatedRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs index 90b76ee99a..688a13bc0b 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs @@ -1,39 +1,36 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy +namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy { /// <summary> - /// Authorization handler for requiring first time setup or elevated privileges. + /// Authorization handler for requiring first time setup or default privileges. /// </summary> - public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler<FirstTimeSetupOrElevatedRequirement> + public class FirstTimeSetupHandler : AuthorizationHandler<FirstTimeSetupRequirement> { private readonly IConfigurationManager _configurationManager; + private readonly IUserManager _userManager; /// <summary> - /// Initializes a new instance of the <see cref="FirstTimeSetupOrElevatedHandler" /> class. + /// Initializes a new instance of the <see cref="FirstTimeSetupHandler" /> class. /// </summary> /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - public FirstTimeSetupOrElevatedHandler( + public FirstTimeSetupHandler( IConfigurationManager configurationManager, - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) + IUserManager userManager) { _configurationManager = configurationManager; + _userManager = userManager; } /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrElevatedRequirement requirement) + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement) { if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) { @@ -41,14 +38,35 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy return Task.CompletedTask; } - var validated = ValidateClaims(context.User); - if (validated && context.User.IsInRole(UserRoles.Administrator)) + var contextUser = context.User; + if (requirement.RequireAdmin && !contextUser.IsInRole(UserRoles.Administrator)) { - context.Succeed(requirement); + context.Fail(); + return Task.CompletedTask; } - else + + var userId = contextUser.GetUserId(); + if (userId.Equals(default)) { context.Fail(); + return Task.CompletedTask; + } + + if (!requirement.ValidateParentalSchedule) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + var user = _userManager.GetUserById(userId); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + if (user.IsParentalScheduleAllowed()) + { + context.Succeed(requirement); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs new file mode 100644 index 0000000000..6252a2feb8 --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs @@ -0,0 +1,25 @@ +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; + +namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy +{ + /// <summary> + /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler. + /// </summary> + public class FirstTimeSetupRequirement : DefaultAuthorizationRequirement + { + /// <summary> + /// Initializes a new instance of the <see cref="FirstTimeSetupRequirement"/> class. + /// </summary> + /// <param name="validateParentalSchedule">A value indicating whether to ignore parental schedule.</param> + /// <param name="requireAdmin">A value indicating whether administrator role is required.</param> + public FirstTimeSetupRequirement(bool validateParentalSchedule = false, bool requireAdmin = true) : base(validateParentalSchedule) + { + RequireAdmin = requireAdmin; + } + + /// <summary> + /// Gets a value indicating whether administrator role is required. + /// </summary> + public bool RequireAdmin { get; } + } +} diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs deleted file mode 100644 index a7623556a9..0000000000 --- a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy -{ - /// <summary> - /// Escape schedule controls handler. - /// </summary> - public class IgnoreParentalControlHandler : BaseAuthorizationHandler<IgnoreParentalControlRequirement> - { - /// <summary> - /// Initializes a new instance of the <see cref="IgnoreParentalControlHandler"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - public IgnoreParentalControlHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement) - { - var validated = ValidateClaims(context.User, ignoreSchedule: true); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs deleted file mode 100644 index cdad74270e..0000000000 --- a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy -{ - /// <summary> - /// Escape schedule controls requirement. - /// </summary> - public class IgnoreParentalControlRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs index 14722aa57e..6ed6fc90be 100644 --- a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs +++ b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs @@ -1,7 +1,7 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Jellyfin.Api.Constants; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -10,27 +10,38 @@ namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy /// <summary> /// Local access or require elevated privileges handler. /// </summary> - public class LocalAccessOrRequiresElevationHandler : BaseAuthorizationHandler<LocalAccessOrRequiresElevationRequirement> + public class LocalAccessOrRequiresElevationHandler : AuthorizationHandler<LocalAccessOrRequiresElevationRequirement> { + private readonly INetworkManager _networkManager; + private readonly IHttpContextAccessor _httpContextAccessor; + /// <summary> /// Initializes a new instance of the <see cref="LocalAccessOrRequiresElevationHandler"/> class. /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> public LocalAccessOrRequiresElevationHandler( - IUserManager userManager, INetworkManager networkManager, IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) { + _networkManager = networkManager; + _httpContextAccessor = httpContextAccessor; } /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessOrRequiresElevationRequirement requirement) { - var validated = ValidateClaims(context.User, localAccessOnly: true); - if (validated || context.User.IsInRole(UserRoles.Administrator)) + var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp(); + + // Loopback will be on LAN, so we can accept null. + if (ip is null || _networkManager.IsInLocalNetwork(ip)) + { + context.Succeed(requirement); + + return Task.CompletedTask; + } + + if (context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(requirement); } diff --git a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs index d9c64d01c4..f633c69d8f 100644 --- a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs +++ b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy { diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs deleted file mode 100644 index d772ec5542..0000000000 --- a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.LocalAccessPolicy -{ - /// <summary> - /// Local access handler. - /// </summary> - public class LocalAccessHandler : BaseAuthorizationHandler<LocalAccessRequirement> - { - /// <summary> - /// Initializes a new instance of the <see cref="LocalAccessHandler"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - public LocalAccessHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement) - { - var validated = ValidateClaims(context.User, localAccessOnly: true); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs deleted file mode 100644 index 761127fa40..0000000000 --- a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.LocalAccessPolicy -{ - /// <summary> - /// The local access authorization requirement. - /// </summary> - public class LocalAccessRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs deleted file mode 100644 index b235c4b63b..0000000000 --- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Threading.Tasks; -using Jellyfin.Api.Constants; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.RequiresElevationPolicy -{ - /// <summary> - /// Authorization handler for requiring elevated privileges. - /// </summary> - public class RequiresElevationHandler : BaseAuthorizationHandler<RequiresElevationRequirement> - { - /// <summary> - /// Initializes a new instance of the <see cref="RequiresElevationHandler"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - public RequiresElevationHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// <inheritdoc /> - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement) - { - var validated = ValidateClaims(context.User); - if (validated && context.User.IsInRole(UserRoles.Administrator)) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs deleted file mode 100644 index cfff1cc0c5..0000000000 --- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.RequiresElevationPolicy -{ - /// <summary> - /// The authorization requirement for requiring elevated privileges in the authorization handler. - /// </summary> - public class RequiresElevationRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs index cdd7d8a52b..75ec9fcec6 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -1,19 +1,17 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Net; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SyncPlay; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { /// <summary> /// Default authorization handler. /// </summary> - public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement> + public class SyncPlayAccessHandler : AuthorizationHandler<SyncPlayAccessRequirement> { private readonly ISyncPlayManager _syncPlayManager; private readonly IUserManager _userManager; @@ -23,14 +21,9 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy /// </summary> /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> public SyncPlayAccessHandler( ISyncPlayManager syncPlayManager, - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) + IUserManager userManager) { _syncPlayManager = syncPlayManager; _userManager = userManager; @@ -39,27 +32,20 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement) { - if (!ValidateClaims(context.User)) - { - context.Fail(); - return Task.CompletedTask; - } - var userId = context.User.GetUserId(); var user = _userManager.GetUserById(userId); + if (user is null) + { + throw new ResourceNotFoundException(); + } if (requirement.RequiredAccess == SyncPlayAccessRequirementType.HasAccess) { - if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups - || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups + if (user.SyncPlayAccess is SyncPlayUserAccessType.CreateAndJoinGroups or SyncPlayUserAccessType.JoinGroups || _syncPlayManager.IsUserActive(userId)) { context.Succeed(requirement); } - else - { - context.Fail(); - } } else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.CreateGroup) { @@ -67,10 +53,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { context.Succeed(requirement); } - else - { - context.Fail(); - } } else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.JoinGroup) { @@ -79,10 +61,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { context.Succeed(requirement); } - else - { - context.Fail(); - } } else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup) { @@ -90,14 +68,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { context.Succeed(requirement); } - else - { - context.Fail(); - } - } - else - { - context.Fail(); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs index 6fab4c0ad8..220b223b39 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs @@ -1,12 +1,12 @@ -using Jellyfin.Data.Enums; -using Microsoft.AspNetCore.Authorization; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Data.Enums; namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { /// <summary> /// The default authorization requirement. /// </summary> - public class SyncPlayAccessRequirement : IAuthorizationRequirement + public class SyncPlayAccessRequirement : DefaultAuthorizationRequirement { /// <summary> /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class. diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs new file mode 100644 index 0000000000..e72bec46fd --- /dev/null +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Extensions; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.UserPermissionPolicy +{ + /// <summary> + /// User permission authorization handler. + /// </summary> + public class UserPermissionHandler : AuthorizationHandler<UserPermissionRequirement> + { + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="UserPermissionHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public UserPermissionHandler(IUserManager userManager) + { + _userManager = userManager; + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement) + { + var user = _userManager.GetUserById(context.User.GetUserId()); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + if (user.HasPermission(requirement.RequiredPermission)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs new file mode 100644 index 0000000000..4694556eb7 --- /dev/null +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs @@ -0,0 +1,26 @@ +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Data.Enums; + +namespace Jellyfin.Api.Auth.UserPermissionPolicy +{ + /// <summary> + /// The user permission requirement. + /// </summary> + public class UserPermissionRequirement : DefaultAuthorizationRequirement + { + /// <summary> + /// Initializes a new instance of the <see cref="UserPermissionRequirement"/> class. + /// </summary> + /// <param name="requiredPermission">The required <see cref="PermissionKind"/>.</param> + /// <param name="validateParentalSchedule">Whether to validate the user's parental schedule.</param> + public UserPermissionRequirement(PermissionKind requiredPermission, bool validateParentalSchedule = true) : base(validateParentalSchedule) + { + RequiredPermission = requiredPermission; + } + + /// <summary> + /// Gets the required user permission. + /// </summary> + public PermissionKind RequiredPermission { get; } + } +} diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs index e327831fe7..5b4bd0adb0 100644 --- a/Jellyfin.Api/BaseJellyfinApiController.cs +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -4,35 +4,34 @@ using Jellyfin.Api.Results; using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api +namespace Jellyfin.Api; + +/// <summary> +/// Base api controller for the API setting a default route. +/// </summary> +[ApiController] +[Route("[controller]")] +[Produces( + MediaTypeNames.Application.Json, + JsonDefaults.CamelCaseMediaType, + JsonDefaults.PascalCaseMediaType)] +public class BaseJellyfinApiController : ControllerBase { /// <summary> - /// Base api controller for the API setting a default route. + /// Create a new <see cref="OkResult{T}"/>. /// </summary> - [ApiController] - [Route("[controller]")] - [Produces( - MediaTypeNames.Application.Json, - JsonDefaults.CamelCaseMediaType, - JsonDefaults.PascalCaseMediaType)] - public class BaseJellyfinApiController : ControllerBase - { - /// <summary> - /// Create a new <see cref="OkResult{T}"/>. - /// </summary> - /// <param name="value">The value to return.</param> - /// <typeparam name="T">The type to return.</typeparam> - /// <returns>The <see cref="ActionResult{T}"/>.</returns> - protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value) - => new OkResult<IEnumerable<T>?>(value); + /// <param name="value">The value to return.</param> + /// <typeparam name="T">The type to return.</typeparam> + /// <returns>The <see cref="ActionResult{T}"/>.</returns> + protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value) + => new OkResult<IEnumerable<T>?>(value); - /// <summary> - /// Create a new <see cref="OkResult{T}"/>. - /// </summary> - /// <param name="value">The value to return.</param> - /// <typeparam name="T">The type to return.</typeparam> - /// <returns>The <see cref="ActionResult{T}"/>.</returns> - protected ActionResult<T> Ok<T>(T value) - => new OkResult<T>(value); - } + /// <summary> + /// Create a new <see cref="OkResult{T}"/>. + /// </summary> + /// <param name="value">The value to return.</param> + /// <typeparam name="T">The type to return.</typeparam> + /// <returns>The <see cref="ActionResult{T}"/>.</returns> + protected ActionResult<T> Ok<T>(T value) + => new OkResult<T>(value); } diff --git a/Jellyfin.Api/Constants/AuthenticationSchemes.cs b/Jellyfin.Api/Constants/AuthenticationSchemes.cs index bac3379e71..d5c2253e4a 100644 --- a/Jellyfin.Api/Constants/AuthenticationSchemes.cs +++ b/Jellyfin.Api/Constants/AuthenticationSchemes.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Authentication schemes for user authentication in the API. +/// </summary> +public static class AuthenticationSchemes { /// <summary> - /// Authentication schemes for user authentication in the API. + /// Scheme name for the custom legacy authentication. /// </summary> - public static class AuthenticationSchemes - { - /// <summary> - /// Scheme name for the custom legacy authentication. - /// </summary> - public const string CustomAuthentication = "CustomAuthentication"; - } + public const string CustomAuthentication = "CustomAuthentication"; } diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs index 8323312e51..73c4acb882 100644 --- a/Jellyfin.Api/Constants/InternalClaimTypes.cs +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -1,43 +1,42 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Internal claim types for authorization. +/// </summary> +public static class InternalClaimTypes { /// <summary> - /// Internal claim types for authorization. + /// User Id. /// </summary> - public static class InternalClaimTypes - { - /// <summary> - /// User Id. - /// </summary> - public const string UserId = "Jellyfin-UserId"; + public const string UserId = "Jellyfin-UserId"; - /// <summary> - /// Device Id. - /// </summary> - public const string DeviceId = "Jellyfin-DeviceId"; + /// <summary> + /// Device Id. + /// </summary> + public const string DeviceId = "Jellyfin-DeviceId"; - /// <summary> - /// Device. - /// </summary> - public const string Device = "Jellyfin-Device"; + /// <summary> + /// Device. + /// </summary> + public const string Device = "Jellyfin-Device"; - /// <summary> - /// Client. - /// </summary> - public const string Client = "Jellyfin-Client"; + /// <summary> + /// Client. + /// </summary> + public const string Client = "Jellyfin-Client"; - /// <summary> - /// Version. - /// </summary> - public const string Version = "Jellyfin-Version"; + /// <summary> + /// Version. + /// </summary> + public const string Version = "Jellyfin-Version"; - /// <summary> - /// Token. - /// </summary> - public const string Token = "Jellyfin-Token"; + /// <summary> + /// Token. + /// </summary> + public const string Token = "Jellyfin-Token"; - /// <summary> - /// Is Api Key. - /// </summary> - public const string IsApiKey = "Jellyfin-IsApiKey"; - } + /// <summary> + /// Is Api Key. + /// </summary> + public const string IsApiKey = "Jellyfin-IsApiKey"; } diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index a72eeea284..53841b0c44 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -1,78 +1,87 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Policies for the API authorization. +/// </summary> +public static class Policies { /// <summary> - /// Policies for the API authorization. - /// </summary> - public static class Policies - { - /// <summary> - /// Policy name for default authorization. - /// </summary> - public const string DefaultAuthorization = "DefaultAuthorization"; - - /// <summary> - /// Policy name for requiring first time setup or elevated privileges. - /// </summary> - public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated"; - - /// <summary> - /// Policy name for requiring elevated privileges. - /// </summary> - public const string RequiresElevation = "RequiresElevation"; - - /// <summary> - /// Policy name for allowing local access only. - /// </summary> - public const string LocalAccessOnly = "LocalAccessOnly"; - - /// <summary> - /// Policy name for escaping schedule controls. - /// </summary> - public const string IgnoreParentalControl = "IgnoreParentalControl"; - - /// <summary> - /// Policy name for requiring download permission. - /// </summary> - public const string Download = "Download"; - - /// <summary> - /// Policy name for requiring first time setup or default permissions. - /// </summary> - public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault"; - - /// <summary> - /// Policy name for requiring local access or elevated privileges. - /// </summary> - public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; - - /// <summary> - /// Policy name for requiring (anonymous) LAN access. - /// </summary> - public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy"; - - /// <summary> - /// Policy name for escaping schedule controls or requiring first time setup. - /// </summary> - public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; - - /// <summary> - /// Policy name for accessing SyncPlay. - /// </summary> - public const string SyncPlayHasAccess = "SyncPlayHasAccess"; - - /// <summary> - /// Policy name for creating a SyncPlay group. - /// </summary> - public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; - - /// <summary> - /// Policy name for joining a SyncPlay group. - /// </summary> - public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; - - /// <summary> - /// Policy name for accessing a SyncPlay group. - /// </summary> - public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; - } + /// Policy name for requiring first time setup or elevated privileges. + /// </summary> + public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated"; + + /// <summary> + /// Policy name for requiring elevated privileges. + /// </summary> + public const string RequiresElevation = "RequiresElevation"; + + /// <summary> + /// Policy name for allowing local access only. + /// </summary> + public const string LocalAccessOnly = "LocalAccessOnly"; + + /// <summary> + /// Policy name for escaping schedule controls. + /// </summary> + public const string IgnoreParentalControl = "IgnoreParentalControl"; + + /// <summary> + /// Policy name for requiring download permission. + /// </summary> + public const string Download = "Download"; + + /// <summary> + /// Policy name for requiring first time setup or default permissions. + /// </summary> + public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault"; + + /// <summary> + /// Policy name for requiring local access or elevated privileges. + /// </summary> + public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; + + /// <summary> + /// Policy name for requiring (anonymous) LAN access. + /// </summary> + public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy"; + + /// <summary> + /// Policy name for escaping schedule controls or requiring first time setup. + /// </summary> + public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; + + /// <summary> + /// Policy name for accessing SyncPlay. + /// </summary> + public const string SyncPlayHasAccess = "SyncPlayHasAccess"; + + /// <summary> + /// Policy name for creating a SyncPlay group. + /// </summary> + public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; + + /// <summary> + /// Policy name for joining a SyncPlay group. + /// </summary> + public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; + + /// <summary> + /// Policy name for accessing a SyncPlay group. + /// </summary> + public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; + + /// <summary> + /// Policy name for accessing collection management. + /// </summary> + public const string CollectionManagement = "CollectionManagement"; + + /// <summary> + /// Policy name for accessing LiveTV. + /// </summary> + public const string LiveTvAccess = "LiveTvAccess"; + + /// <summary> + /// Policy name for managing LiveTV. + /// </summary> + public const string LiveTvManagement = "LiveTvManagement"; } diff --git a/Jellyfin.Api/Constants/UserRoles.cs b/Jellyfin.Api/Constants/UserRoles.cs index d9a536e7d7..41c7b7cd0f 100644 --- a/Jellyfin.Api/Constants/UserRoles.cs +++ b/Jellyfin.Api/Constants/UserRoles.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Constants for user roles used in the authentication and authorization for the API. +/// </summary> +public static class UserRoles { /// <summary> - /// Constants for user roles used in the authentication and authorization for the API. + /// Guest user. /// </summary> - public static class UserRoles - { - /// <summary> - /// Guest user. - /// </summary> - public const string Guest = "Guest"; + public const string Guest = "Guest"; - /// <summary> - /// Regular user with no special privileges. - /// </summary> - public const string User = "User"; + /// <summary> + /// Regular user with no special privileges. + /// </summary> + public const string User = "User"; - /// <summary> - /// Administrator user with elevated privileges. - /// </summary> - public const string Administrator = "Administrator"; - } + /// <summary> + /// Administrator user with elevated privileges. + /// </summary> + public const string Administrator = "Administrator"; } diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index ae45f647f7..c3d02976eb 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -8,50 +8,49 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Activity log controller. +/// </summary> +[Route("System/ActivityLog")] +[Authorize(Policy = Policies.RequiresElevation)] +public class ActivityLogController : BaseJellyfinApiController { + private readonly IActivityManager _activityManager; + /// <summary> - /// Activity log controller. + /// Initializes a new instance of the <see cref="ActivityLogController"/> class. /// </summary> - [Route("System/ActivityLog")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ActivityLogController : BaseJellyfinApiController + /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param> + public ActivityLogController(IActivityManager activityManager) { - private readonly IActivityManager _activityManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ActivityLogController"/> class. - /// </summary> - /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param> - public ActivityLogController(IActivityManager activityManager) - { - _activityManager = activityManager; - } + _activityManager = activityManager; + } - /// <summary> - /// Gets activity log entries. - /// </summary> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="minDate">Optional. The minimum date. Format = ISO.</param> - /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param> - /// <response code="200">Activity log returned.</response> - /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> - [HttpGet("Entries")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] DateTime? minDate, - [FromQuery] bool? hasUserId) + /// <summary> + /// Gets activity log entries. + /// </summary> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="minDate">Optional. The minimum date. Format = ISO.</param> + /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param> + /// <response code="200">Activity log returned.</response> + /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> + [HttpGet("Entries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] DateTime? minDate, + [FromQuery] bool? hasUserId) + { + return await _activityManager.GetPagedResultAsync(new ActivityLogQuery { - return await _activityManager.GetPagedResultAsync(new ActivityLogQuery - { - Skip = startIndex, - Limit = limit, - MinDate = minDate, - HasUserId = hasUserId - }).ConfigureAwait(false); - } + Skip = startIndex, + Limit = limit, + MinDate = minDate, + HasUserId = hasUserId + }).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index 024a15349e..991f8cbf20 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -7,70 +7,69 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Authentication controller. +/// </summary> +[Route("Auth")] +public class ApiKeyController : BaseJellyfinApiController { + private readonly IAuthenticationManager _authenticationManager; + /// <summary> - /// Authentication controller. + /// Initializes a new instance of the <see cref="ApiKeyController"/> class. /// </summary> - [Route("Auth")] - public class ApiKeyController : BaseJellyfinApiController + /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param> + public ApiKeyController(IAuthenticationManager authenticationManager) { - private readonly IAuthenticationManager _authenticationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ApiKeyController"/> class. - /// </summary> - /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param> - public ApiKeyController(IAuthenticationManager authenticationManager) - { - _authenticationManager = authenticationManager; - } + _authenticationManager = authenticationManager; + } - /// <summary> - /// Get all keys. - /// </summary> - /// <response code="200">Api keys retrieved.</response> - /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns> - [HttpGet("Keys")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys() - { - var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); + /// <summary> + /// Get all keys. + /// </summary> + /// <response code="200">Api keys retrieved.</response> + /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns> + [HttpGet("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys() + { + var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); - return new QueryResult<AuthenticationInfo>(keys); - } + return new QueryResult<AuthenticationInfo>(keys); + } - /// <summary> - /// Create a new api key. - /// </summary> - /// <param name="app">Name of the app using the authentication key.</param> - /// <response code="204">Api key created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Keys")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CreateKey([FromQuery, Required] string app) - { - await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); + /// <summary> + /// Create a new api key. + /// </summary> + /// <param name="app">Name of the app using the authentication key.</param> + /// <response code="204">Api key created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CreateKey([FromQuery, Required] string app) + { + await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); + } - /// <summary> - /// Remove an api key. - /// </summary> - /// <param name="key">The access token to delete.</param> - /// <response code="204">Api key deleted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Keys/{key}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RevokeKey([FromRoute, Required] string key) - { - await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false); + /// <summary> + /// Remove an api key. + /// </summary> + /// <param name="key">The access token to delete.</param> + /// <response code="204">Api key deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Keys/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RevokeKey([FromRoute, Required] string key) + { + await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index c8ac2ed526..c9d2f67f92 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -17,464 +16,466 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The artists controller. +/// </summary> +[Route("Artists")] +[Authorize] +public class ArtistsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// <summary> - /// The artists controller. + /// Initializes a new instance of the <see cref="ArtistsController"/> class. /// </summary> - [Route("Artists")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ArtistsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public ArtistsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="ArtistsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public ArtistsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } + + /// <summary> + /// Gets all artists from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. 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="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Artists returned.</response> + /// <returns>An <see cref="OkResult"/> containing the artists.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetArtists( + [FromQuery] double? minCommunityRating, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + User? user = null; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); + + if (!userId.Value.Equals(default)) { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; + user = _userManager.GetUserById(userId.Value); } - /// <summary> - /// Gets all artists from a given item, folder, or the entire library. - /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="searchTerm">Optional. Search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. 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="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Total record count.</param> - /// <response code="200">Artists returned.</response> - /// <returns>An <see cref="OkResult"/> containing the artists.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetArtists( - [FromQuery] double? minCommunityRating, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) + var query = new InternalItemsQuery(user) { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + MinCommunityRating = minCommunityRating, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; - User? user = null; - BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - - if (userId.HasValue && !userId.Equals(default)) + if (parentId.HasValue) + { + if (parentItem is Folder) { - user = _userManager.GetUserById(userId.Value); + query.AncestorIds = new[] { parentId.Value }; } - - var query = new InternalItemsQuery(user) + else { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - MinCommunityRating = minCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) + query.ItemIds = new[] { parentId.Value }; + } + } + + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - if (parentItem is Folder) + try { - query.AncestorIds = new[] { parentId.Value }; + return _libraryManager.GetStudio(i); } - else + catch { - query.ItemIds = new[] { parentId.Value }; + return null; } - } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // Studios - if (studios.Length != 0) + foreach (var filter in filters) + { + switch (filter) { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; } + } - foreach (var filter in filters) + var result = _libraryManager.GetArtists(query); + + var dtos = result.Items.Select(i => + { + var (baseItem, itemCounts) = i; + var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + + if (includeItemTypes.Length != 0) { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } + dto.ChildCount = itemCounts.ItemCount; + dto.ProgramCount = itemCounts.ProgramCount; + dto.SeriesCount = itemCounts.SeriesCount; + dto.EpisodeCount = itemCounts.EpisodeCount; + dto.MovieCount = itemCounts.MovieCount; + dto.TrailerCount = itemCounts.TrailerCount; + dto.AlbumCount = itemCounts.AlbumCount; + dto.SongCount = itemCounts.SongCount; + dto.ArtistCount = itemCounts.ArtistCount; } - var result = _libraryManager.GetArtists(query); + return dto; + }); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + return new QueryResult<BaseItemDto>( + query.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); + } - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } + /// <summary> + /// Gets all album artists from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. 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="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Album artists returned.</response> + /// <returns>An <see cref="OkResult"/> containing the album artists.</returns> + [HttpGet("AlbumArtists")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists( + [FromQuery] double? minCommunityRating, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - return dto; - }); + User? user = null; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + if (!userId.Value.Equals(default)) + { + user = _userManager.GetUserById(userId.Value); } - /// <summary> - /// Gets all album artists from a given item, folder, or the entire library. - /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="searchTerm">Optional. Search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. 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="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Total record count.</param> - /// <response code="200">Album artists returned.</response> - /// <returns>An <see cref="OkResult"/> containing the album artists.</returns> - [HttpGet("AlbumArtists")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists( - [FromQuery] double? minCommunityRating, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) + var query = new InternalItemsQuery(user) { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - User? user = null; - BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + MinCommunityRating = minCommunityRating, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; - if (userId.HasValue && !userId.Equals(default)) + if (parentId.HasValue) + { + if (parentItem is Folder) { - user = _userManager.GetUserById(userId.Value); + query.AncestorIds = new[] { parentId.Value }; } - - var query = new InternalItemsQuery(user) + else { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - MinCommunityRating = minCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) + query.ItemIds = new[] { parentId.Value }; + } + } + + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - if (parentItem is Folder) + try { - query.AncestorIds = new[] { parentId.Value }; + return _libraryManager.GetStudio(i); } - else + catch { - query.ItemIds = new[] { parentId.Value }; + return null; } - } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // Studios - if (studios.Length != 0) + foreach (var filter in filters) + { + switch (filter) { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; } + } - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } + var result = _libraryManager.GetAlbumArtists(query); - var result = _libraryManager.GetAlbumArtists(query); + var dtos = result.Items.Select(i => + { + var (baseItem, itemCounts) = i; + var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - var dtos = result.Items.Select(i => + if (includeItemTypes.Length != 0) { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); + dto.ChildCount = itemCounts.ItemCount; + dto.ProgramCount = itemCounts.ProgramCount; + dto.SeriesCount = itemCounts.SeriesCount; + dto.EpisodeCount = itemCounts.EpisodeCount; + dto.MovieCount = itemCounts.MovieCount; + dto.TrailerCount = itemCounts.TrailerCount; + dto.AlbumCount = itemCounts.AlbumCount; + dto.SongCount = itemCounts.SongCount; + dto.ArtistCount = itemCounts.ArtistCount; + } - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); - } + return dto; + }); - /// <summary> - /// Gets an artist by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Artist returned.</response> - /// <returns>An <see cref="OkResult"/> containing the artist.</returns> - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); + return new QueryResult<BaseItemDto>( + query.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); + } - var item = _libraryManager.GetArtist(name, dtoOptions); + /// <summary> + /// Gets an artist by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Artist returned.</response> + /// <returns>An <see cref="OkResult"/> containing the artist.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions().AddClientFields(User); - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetArtist(name, dtoOptions); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + if (!userId.Value.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 94f7a7b827..968193a6f8 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -10,355 +10,354 @@ using MediaBrowser.Model.Dlna; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The audio controller. +/// </summary> +// TODO: In order to authenticate this in the future, Dlna playback will require updating +public class AudioController : BaseJellyfinApiController { + private readonly AudioHelper _audioHelper; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; + /// <summary> - /// The audio controller. + /// Initializes a new instance of the <see cref="AudioController"/> class. /// </summary> - // TODO: In order to authenticate this in the future, Dlna playback will require updating - public class AudioController : BaseJellyfinApiController + /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> + public AudioController(AudioHelper audioHelper) { - private readonly AudioHelper _audioHelper; - - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; - - /// <summary> - /// Initializes a new instance of the <see cref="AudioController"/> class. - /// </summary> - /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> - public AudioController(AudioHelper audioHelper) - { - _audioHelper = audioHelper; - } + _audioHelper = audioHelper; + } - /// <summary> - /// Gets an audio stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The audio container.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Audio stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream", Name = "GetAudioStream")] - [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - public async Task<ActionResult> GetAudioStream( - [FromRoute, Required] Guid itemId, - [FromQuery] string? container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string>? streamOptions) + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream", Name = "GetAudioStream")] + [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + public async Task<ActionResult> GetAudioStream( + [FromRoute, Required] Guid itemId, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string>? streamOptions) + { + StreamingRequestDto streamingRequest = new StreamingRequestDto { - StreamingRequestDto streamingRequest = new StreamingRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Static, - StreamOptions = streamOptions - }; + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Static, + StreamOptions = streamOptions + }; - return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); - } + return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); + } - /// <summary> - /// Gets an audio stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The audio container.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Audio stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] - [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - public async Task<ActionResult> GetAudioStreamByContainer( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string>? streamOptions) + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] + [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + public async Task<ActionResult> GetAudioStreamByContainer( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string>? streamOptions) + { + StreamingRequestDto streamingRequest = new StreamingRequestDto { - StreamingRequestDto streamingRequest = new StreamingRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Static, - StreamOptions = streamOptions - }; + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Static, + StreamOptions = streamOptions + }; - return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); - } + return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs index d3ea412015..3c2c4b4dbd 100644 --- a/Jellyfin.Api/Controllers/BrandingController.cs +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -4,54 +4,53 @@ using MediaBrowser.Model.Branding; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Branding controller. +/// </summary> +public class BrandingController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Branding controller. + /// Initializes a new instance of the <see cref="BrandingController"/> class. /// </summary> - public class BrandingController : BaseJellyfinApiController + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public BrandingController(IServerConfigurationManager serverConfigurationManager) { - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="BrandingController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public BrandingController(IServerConfigurationManager serverConfigurationManager) - { - _serverConfigurationManager = serverConfigurationManager; - } + _serverConfigurationManager = serverConfigurationManager; + } - /// <summary> - /// Gets branding configuration. - /// </summary> - /// <response code="200">Branding configuration returned.</response> - /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns> - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BrandingOptions> GetBrandingOptions() - { - return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - } + /// <summary> + /// Gets branding configuration. + /// </summary> + /// <response code="200">Branding configuration returned.</response> + /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BrandingOptions> GetBrandingOptions() + { + return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + } - /// <summary> - /// Gets branding css. - /// </summary> - /// <response code="200">Branding css returned.</response> - /// <response code="204">No branding css configured.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the branding css if exist, - /// or a <see cref="NoContentResult"/> if the css is not configured. - /// </returns> - [HttpGet("Css")] - [HttpGet("Css.css", Name = "GetBrandingCss_2")] - [Produces("text/css")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult<string> GetBrandingCss() - { - var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - return options.CustomCss ?? string.Empty; - } + /// <summary> + /// Gets branding css. + /// </summary> + /// <response code="200">Branding css returned.</response> + /// <response code="204">No branding css configured.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the branding css if exist, + /// or a <see cref="NoContentResult"/> if the css is not configured. + /// </returns> + [HttpGet("Css")] + [HttpGet("Css.css", Name = "GetBrandingCss_2")] + [Produces("text/css")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult<string> GetBrandingCss() + { + var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + return options.CustomCss ?? string.Empty; } } diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index d5b589a3fa..11c4ac3768 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -18,234 +17,236 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Channels Controller. +/// </summary> +[Authorize] +public class ChannelsController : BaseJellyfinApiController { + private readonly IChannelManager _channelManager; + private readonly IUserManager _userManager; + /// <summary> - /// Channels Controller. + /// Initializes a new instance of the <see cref="ChannelsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ChannelsController : BaseJellyfinApiController + /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public ChannelsController(IChannelManager channelManager, IUserManager userManager) { - private readonly IChannelManager _channelManager; - private readonly IUserManager _userManager; + _channelManager = channelManager; + _userManager = userManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="ChannelsController"/> class. - /// </summary> - /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public ChannelsController(IChannelManager channelManager, IUserManager userManager) + /// <summary> + /// Gets available channels. + /// </summary> + /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param> + /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param> + /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param> + /// <response code="200">Channels returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channels.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannels( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? supportsLatestItems, + [FromQuery] bool? supportsMediaDeletion, + [FromQuery] bool? isFavorite) + { + userId = RequestHelpers.GetUserId(User, userId); + return await _channelManager.GetChannelsAsync(new ChannelQuery { - _channelManager = channelManager; - _userManager = userManager; - } + Limit = limit, + StartIndex = startIndex, + UserId = userId.Value, + SupportsLatestItems = supportsLatestItems, + SupportsMediaDeletion = supportsMediaDeletion, + IsFavorite = isFavorite + }).ConfigureAwait(false); + } - /// <summary> - /// Gets available channels. - /// </summary> - /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param> - /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param> - /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param> - /// <response code="200">Channels returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channels.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetChannels( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? supportsLatestItems, - [FromQuery] bool? supportsMediaDeletion, - [FromQuery] bool? isFavorite) - { - return _channelManager.GetChannels(new ChannelQuery - { - Limit = limit, - StartIndex = startIndex, - UserId = userId ?? Guid.Empty, - SupportsLatestItems = supportsLatestItems, - SupportsMediaDeletion = supportsMediaDeletion, - IsFavorite = isFavorite - }); - } + /// <summary> + /// Get all channel features. + /// </summary> + /// <response code="200">All channel features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> + [HttpGet("Features")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures() + { + return _channelManager.GetAllChannelFeatures(); + } - /// <summary> - /// Get all channel features. - /// </summary> - /// <response code="200">All channel features returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> - [HttpGet("Features")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures() - { - return _channelManager.GetAllChannelFeatures(); - } + /// <summary> + /// Get channel features. + /// </summary> + /// <param name="channelId">Channel id.</param> + /// <response code="200">Channel features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> + [HttpGet("{channelId}/Features")] + public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId) + { + return _channelManager.GetChannelFeatures(channelId); + } - /// <summary> - /// Get channel features. - /// </summary> - /// <param name="channelId">Channel id.</param> - /// <response code="200">Channel features returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> - [HttpGet("{channelId}/Features")] - public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId) - { - return _channelManager.GetChannelFeatures(channelId); - } + /// <summary> + /// Get channel items. + /// </summary> + /// <param name="channelId">Channel Id.</param> + /// <param name="folderId">Optional. Folder Id.</param> + /// <param name="userId">Optional. User Id.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <response code="200">Channel items returned.</response> + /// <returns> + /// A <see cref="Task"/> representing the request to get the channel items. + /// The task result contains an <see cref="OkResult"/> containing the channel items. + /// </returns> + [HttpGet("{channelId}/Items")] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems( + [FromRoute, Required] Guid channelId, + [FromQuery] Guid? folderId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Get channel items. - /// </summary> - /// <param name="channelId">Channel Id.</param> - /// <param name="folderId">Optional. Folder Id.</param> - /// <param name="userId">Optional. User Id.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <response code="200">Channel items returned.</response> - /// <returns> - /// A <see cref="Task"/> representing the request to get the channel items. - /// The task result contains an <see cref="OkResult"/> containing the channel items. - /// </returns> - [HttpGet("{channelId}/Items")] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems( - [FromRoute, Required] Guid channelId, - [FromQuery] Guid? folderId, - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + var query = new InternalItemsQuery(user) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + Limit = limit, + StartIndex = startIndex, + ChannelIds = new[] { channelId }, + ParentId = folderId ?? Guid.Empty, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + DtoOptions = new DtoOptions { Fields = fields } + }; - var query = new InternalItemsQuery(user) - { - Limit = limit, - StartIndex = startIndex, - ChannelIds = new[] { channelId }, - ParentId = folderId ?? Guid.Empty, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - DtoOptions = new DtoOptions { Fields = fields } - }; - - foreach (var filter in filters) + foreach (var filter in filters) + { + switch (filter) { - switch (filter) - { - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - } + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; } - - return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); } - /// <summary> - /// Gets latest channel items. - /// </summary> - /// <param name="userId">Optional. User Id.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param> - /// <response code="200">Latest channel items returned.</response> - /// <returns> - /// A <see cref="Task"/> representing the request to get the latest channel items. - /// The task result contains an <see cref="OkResult"/> containing the latest channel items. - /// </returns> - [HttpGet("Items/Latest")] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); + } - var query = new InternalItemsQuery(user) - { - Limit = limit, - StartIndex = startIndex, - ChannelIds = channelIds, - DtoOptions = new DtoOptions { Fields = fields } - }; + /// <summary> + /// Gets latest channel items. + /// </summary> + /// <param name="userId">Optional. User Id.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param> + /// <response code="200">Latest channel items returned.</response> + /// <returns> + /// A <see cref="Task"/> representing the request to get the latest channel items. + /// The task result contains an <see cref="OkResult"/> containing the latest channel items. + /// </returns> + [HttpGet("Items/Latest")] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - foreach (var filter in filters) + var query = new InternalItemsQuery(user) + { + Limit = limit, + StartIndex = startIndex, + ChannelIds = channelIds, + DtoOptions = new DtoOptions { Fields = fields } + }; + + foreach (var filter in filters) + { + switch (filter) { - switch (filter) - { - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - } + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; } - - return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); } + + return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/ClientLogController.cs b/Jellyfin.Api/Controllers/ClientLogController.cs index ed073a687e..2c5dbacbbe 100644 --- a/Jellyfin.Api/Controllers/ClientLogController.cs +++ b/Jellyfin.Api/Controllers/ClientLogController.cs @@ -1,9 +1,7 @@ using System.Net.Mime; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.ClientLogDtos; using MediaBrowser.Controller.ClientEvent; using MediaBrowser.Controller.Configuration; @@ -11,71 +9,70 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Client log controller. +/// </summary> +[Authorize] +public class ClientLogController : BaseJellyfinApiController { + private const int MaxDocumentSize = 1_000_000; + private readonly IClientEventLogger _clientEventLogger; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Client log controller. + /// Initializes a new instance of the <see cref="ClientLogController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ClientLogController : BaseJellyfinApiController + /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ClientLogController( + IClientEventLogger clientEventLogger, + IServerConfigurationManager serverConfigurationManager) { - private const int MaxDocumentSize = 1_000_000; - private readonly IClientEventLogger _clientEventLogger; - private readonly IServerConfigurationManager _serverConfigurationManager; + _clientEventLogger = clientEventLogger; + _serverConfigurationManager = serverConfigurationManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="ClientLogController"/> class. - /// </summary> - /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public ClientLogController( - IClientEventLogger clientEventLogger, - IServerConfigurationManager serverConfigurationManager) + /// <summary> + /// Upload a document. + /// </summary> + /// <response code="200">Document saved.</response> + /// <response code="403">Event logging disabled.</response> + /// <response code="413">Upload size too large.</response> + /// <returns>Create response.</returns> + [HttpPost("Document")] + [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [AcceptsFile(MediaTypeNames.Text.Plain)] + [RequestSizeLimit(MaxDocumentSize)] + public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile() + { + if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) { - _clientEventLogger = clientEventLogger; - _serverConfigurationManager = serverConfigurationManager; + return Forbid(); } - /// <summary> - /// Upload a document. - /// </summary> - /// <response code="200">Document saved.</response> - /// <response code="403">Event logging disabled.</response> - /// <response code="413">Upload size too large.</response> - /// <returns>Create response.</returns> - [HttpPost("Document")] - [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] - [AcceptsFile(MediaTypeNames.Text.Plain)] - [RequestSizeLimit(MaxDocumentSize)] - public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile() + if (Request.ContentLength > MaxDocumentSize) { - if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) - { - return Forbid(); - } - - if (Request.ContentLength > MaxDocumentSize) - { - // Manually validate to return proper status code. - return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); - } - - var (clientName, clientVersion) = GetRequestInformation(); - var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) - .ConfigureAwait(false); - return Ok(new ClientLogDocumentResponseDto(fileName)); + // Manually validate to return proper status code. + return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); } - private (string ClientName, string ClientVersion) GetRequestInformation() - { - var clientName = HttpContext.User.GetClient() ?? "unknown-client"; - var clientVersion = HttpContext.User.GetIsApiKey() - ? "apikey" - : HttpContext.User.GetVersion() ?? "unknown-version"; + var (clientName, clientVersion) = GetRequestInformation(); + var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) + .ConfigureAwait(false); + return Ok(new ClientLogDocumentResponseDto(fileName)); + } - return (clientName, clientVersion); - } + private (string ClientName, string ClientVersion) GetRequestInformation() + { + var clientName = HttpContext.User.GetClient() ?? "unknown-client"; + var clientVersion = HttpContext.User.GetIsApiKey() + ? "apikey" + : HttpContext.User.GetVersion() ?? "unknown-version"; + + return (clientName, clientVersion); } } diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index effc9ed7aa..2db04afb80 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -11,101 +11,100 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The collection controller. +/// </summary> +[Route("Collections")] +[Authorize(Policy = Policies.CollectionManagement)] +public class CollectionController : BaseJellyfinApiController { + private readonly ICollectionManager _collectionManager; + private readonly IDtoService _dtoService; + /// <summary> - /// The collection controller. + /// Initializes a new instance of the <see cref="CollectionController"/> class. /// </summary> - [Route("Collections")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class CollectionController : BaseJellyfinApiController + /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + public CollectionController( + ICollectionManager collectionManager, + IDtoService dtoService) { - private readonly ICollectionManager _collectionManager; - private readonly IDtoService _dtoService; + _collectionManager = collectionManager; + _dtoService = dtoService; + } - /// <summary> - /// Initializes a new instance of the <see cref="CollectionController"/> class. - /// </summary> - /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param> - /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> - public CollectionController( - ICollectionManager collectionManager, - IDtoService dtoService) - { - _collectionManager = collectionManager; - _dtoService = dtoService; - } + /// <summary> + /// Creates a new collection. + /// </summary> + /// <param name="name">The name of the collection.</param> + /// <param name="ids">Item Ids to add to the collection.</param> + /// <param name="parentId">Optional. Create the collection within a specific folder.</param> + /// <param name="isLocked">Whether or not to lock the new collection.</param> + /// <response code="200">Collection created.</response> + /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<CollectionCreationResult>> CreateCollection( + [FromQuery] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, + [FromQuery] Guid? parentId, + [FromQuery] bool isLocked = false) + { + var userId = User.GetUserId(); - /// <summary> - /// Creates a new collection. - /// </summary> - /// <param name="name">The name of the collection.</param> - /// <param name="ids">Item Ids to add to the collection.</param> - /// <param name="parentId">Optional. Create the collection within a specific folder.</param> - /// <param name="isLocked">Whether or not to lock the new collection.</param> - /// <response code="200">Collection created.</response> - /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns> - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<CollectionCreationResult>> CreateCollection( - [FromQuery] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, - [FromQuery] Guid? parentId, - [FromQuery] bool isLocked = false) + var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions { - var userId = User.GetUserId(); - - var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions - { - IsLocked = isLocked, - Name = name, - ParentId = parentId, - ItemIdList = ids, - UserIds = new[] { userId } - }).ConfigureAwait(false); + IsLocked = isLocked, + Name = name, + ParentId = parentId, + ItemIdList = ids, + UserIds = new[] { userId } + }).ConfigureAwait(false); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions().AddClientFields(User); - var dto = _dtoService.GetBaseItemDto(item, dtoOptions); + var dto = _dtoService.GetBaseItemDto(item, dtoOptions); - return new CollectionCreationResult - { - Id = dto.Id - }; - } - - /// <summary> - /// Adds items to a collection. - /// </summary> - /// <param name="collectionId">The collection id.</param> - /// <param name="ids">Item ids, comma delimited.</param> - /// <response code="204">Items added to collection.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("{collectionId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddToCollection( - [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + return new CollectionCreationResult { - await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); - return NoContent(); - } + Id = dto.Id + }; + } - /// <summary> - /// Removes items from a collection. - /// </summary> - /// <param name="collectionId">The collection id.</param> - /// <param name="ids">Item ids, comma delimited.</param> - /// <response code="204">Items removed from collection.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpDelete("{collectionId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromCollection( - [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) - { - await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Adds items to a collection. + /// </summary> + /// <param name="collectionId">The collection id.</param> + /// <param name="ids">Item ids, comma delimited.</param> + /// <response code="204">Items added to collection.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddToCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); + return NoContent(); + } + + /// <summary> + /// Removes items from a collection. + /// </summary> + /// <param name="collectionId">The collection id.</param> + /// <param name="ids">Item ids, comma delimited.</param> + /// <response code="204">Items removed from collection.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpDelete("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveFromCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index a00ac1b0af..9007dfc410 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -13,124 +13,123 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Configuration Controller. +/// </summary> +[Route("System")] +[Authorize] +public class ConfigurationController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _configurationManager; + private readonly IMediaEncoder _mediaEncoder; + + private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options; + /// <summary> - /// Configuration Controller. + /// Initializes a new instance of the <see cref="ConfigurationController"/> class. /// </summary> - [Route("System")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ConfigurationController : BaseJellyfinApiController + /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + public ConfigurationController( + IServerConfigurationManager configurationManager, + IMediaEncoder mediaEncoder) { - private readonly IServerConfigurationManager _configurationManager; - private readonly IMediaEncoder _mediaEncoder; + _configurationManager = configurationManager; + _mediaEncoder = mediaEncoder; + } - private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options; + /// <summary> + /// Gets application configuration. + /// </summary> + /// <response code="200">Application configuration returned.</response> + /// <returns>Application configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ServerConfiguration> GetConfiguration() + { + return _configurationManager.Configuration; + } - /// <summary> - /// Initializes a new instance of the <see cref="ConfigurationController"/> class. - /// </summary> - /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - public ConfigurationController( - IServerConfigurationManager configurationManager, - IMediaEncoder mediaEncoder) - { - _configurationManager = configurationManager; - _mediaEncoder = mediaEncoder; - } + /// <summary> + /// Updates application configuration. + /// </summary> + /// <param name="configuration">Configuration.</param> + /// <response code="204">Configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration) + { + _configurationManager.ReplaceConfiguration(configuration); + return NoContent(); + } - /// <summary> - /// Gets application configuration. - /// </summary> - /// <response code="200">Application configuration returned.</response> - /// <returns>Application configuration.</returns> - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<ServerConfiguration> GetConfiguration() - { - return _configurationManager.Configuration; - } + /// <summary> + /// Gets a named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <response code="200">Configuration returned.</response> + /// <returns>Configuration.</returns> + [HttpGet("Configuration/{key}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Json)] + public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key) + { + return _configurationManager.GetConfiguration(key); + } - /// <summary> - /// Updates application configuration. - /// </summary> - /// <param name="configuration">Configuration.</param> - /// <response code="204">Configuration updated.</response> - /// <returns>Update status.</returns> - [HttpPost("Configuration")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration) - { - _configurationManager.ReplaceConfiguration(configuration); - return NoContent(); - } + /// <summary> + /// Updates named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <param name="configuration">Configuration.</param> + /// <response code="204">Named configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration) + { + var configurationType = _configurationManager.GetConfigurationType(key); + var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions); - /// <summary> - /// Gets a named configuration. - /// </summary> - /// <param name="key">Configuration key.</param> - /// <response code="200">Configuration returned.</response> - /// <returns>Configuration.</returns> - [HttpGet("Configuration/{key}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Application.Json)] - public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key) + if (deserializedConfiguration is null) { - return _configurationManager.GetConfiguration(key); + throw new ArgumentException("Body doesn't contain a valid configuration"); } - /// <summary> - /// Updates named configuration. - /// </summary> - /// <param name="key">Configuration key.</param> - /// <param name="configuration">Configuration.</param> - /// <response code="204">Named configuration updated.</response> - /// <returns>Update status.</returns> - [HttpPost("Configuration/{key}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration) - { - var configurationType = _configurationManager.GetConfigurationType(key); - var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions); - - if (deserializedConfiguration is null) - { - throw new ArgumentException("Body doesn't contain a valid configuration"); - } - - _configurationManager.SaveConfiguration(key, deserializedConfiguration); - return NoContent(); - } + _configurationManager.SaveConfiguration(key, deserializedConfiguration); + return NoContent(); + } - /// <summary> - /// Gets a default MetadataOptions object. - /// </summary> - /// <response code="200">Metadata options returned.</response> - /// <returns>Default MetadataOptions.</returns> - [HttpGet("Configuration/MetadataOptions/Default")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<MetadataOptions> GetDefaultMetadataOptions() - { - return new MetadataOptions(); - } + /// <summary> + /// Gets a default MetadataOptions object. + /// </summary> + /// <response code="200">Metadata options returned.</response> + /// <returns>Default MetadataOptions.</returns> + [HttpGet("Configuration/MetadataOptions/Default")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<MetadataOptions> GetDefaultMetadataOptions() + { + return new MetadataOptions(); + } - /// <summary> - /// Updates the path to the media encoder. - /// </summary> - /// <param name="mediaEncoderPath">Media encoder path form body.</param> - /// <response code="204">Media encoder path updated.</response> - /// <returns>Status.</returns> - [HttpPost("MediaEncoder/Path")] - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath) - { - _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); - return NoContent(); - } + /// <summary> + /// Updates the path to the media encoder. + /// </summary> + /// <param name="mediaEncoderPath">Media encoder path form body.</param> + /// <response code="204">Media encoder path updated.</response> + /// <returns>Status.</returns> + [HttpPost("MediaEncoder/Path")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath) + { + _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 3894e6c5fc..076084c7a3 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Net.Mime; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Models; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Net; @@ -14,103 +13,102 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The dashboard controller. +/// </summary> +[Route("")] +public class DashboardController : BaseJellyfinApiController { + private readonly ILogger<DashboardController> _logger; + private readonly IPluginManager _pluginManager; + /// <summary> - /// The dashboard controller. + /// Initializes a new instance of the <see cref="DashboardController"/> class. /// </summary> - [Route("")] - public class DashboardController : BaseJellyfinApiController + /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> + /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param> + public DashboardController( + ILogger<DashboardController> logger, + IPluginManager pluginManager) { - private readonly ILogger<DashboardController> _logger; - private readonly IPluginManager _pluginManager; + _logger = logger; + _pluginManager = pluginManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="DashboardController"/> class. - /// </summary> - /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> - /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param> - public DashboardController( - ILogger<DashboardController> logger, - IPluginManager pluginManager) - { - _logger = logger; - _pluginManager = pluginManager; - } + /// <summary> + /// Gets the configuration pages. + /// </summary> + /// <param name="enableInMainMenu">Whether to enable in the main menu.</param> + /// <response code="200">ConfigurationPages returned.</response> + /// <response code="404">Server still loading.</response> + /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> + [HttpGet("web/ConfigurationPages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize] + public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages( + [FromQuery] bool? enableInMainMenu) + { + var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList(); - /// <summary> - /// Gets the configuration pages. - /// </summary> - /// <param name="enableInMainMenu">Whether to enable in the main menu.</param> - /// <response code="200">ConfigurationPages returned.</response> - /// <response code="404">Server still loading.</response> - /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> - [HttpGet("web/ConfigurationPages")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages( - [FromQuery] bool? enableInMainMenu) + if (enableInMainMenu.HasValue) { - var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList(); - - if (enableInMainMenu.HasValue) - { - configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); - } - - return configPages; + configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); } - /// <summary> - /// Gets a dashboard configuration page. - /// </summary> - /// <param name="name">The name of the page.</param> - /// <response code="200">ConfigurationPage returned.</response> - /// <response code="404">Plugin configuration page not found.</response> - /// <returns>The configuration page.</returns> - [HttpGet("web/ConfigurationPage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")] - public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) - { - var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); - if (altPage is null) - { - return NotFound(); - } - - IPlugin plugin = altPage.Item2; - string resourcePath = altPage.Item1.EmbeddedResourcePath; - Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath); - if (stream is null) - { - _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name); - return NotFound(); - } + return configPages; + } - return File(stream, MimeTypes.GetMimeType(resourcePath)); + /// <summary> + /// Gets a dashboard configuration page. + /// </summary> + /// <param name="name">The name of the page.</param> + /// <response code="200">ConfigurationPage returned.</response> + /// <response code="404">Plugin configuration page not found.</response> + /// <returns>The configuration page.</returns> + [HttpGet("web/ConfigurationPage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")] + public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) + { + var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); + if (altPage is null) + { + return NotFound(); } - private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin) + IPlugin plugin = altPage.Item2; + string resourcePath = altPage.Item1.EmbeddedResourcePath; + Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath); + if (stream is null) { - return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1)); + _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name); + return NotFound(); } - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin) - { - if (plugin.Instance is not IHasWebPages hasWebPages) - { - return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>(); - } + return File(stream, MimeTypes.GetMimeType(resourcePath)); + } - return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance)); - } + private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin) + { + return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1)); + } - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin) + { + if (plugin.Instance is not IHasWebPages hasWebPages) { - return _pluginManager.Plugins.SelectMany(GetPluginPages); + return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>(); } + + return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance)); + } + + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() + { + return _pluginManager.Plugins.SelectMany(GetPluginPages); } } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index aad60cf5cc..aa0dff2123 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Dtos; using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Queries; @@ -13,129 +14,129 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Devices Controller. +/// </summary> +[Authorize(Policy = Policies.RequiresElevation)] +public class DevicesController : BaseJellyfinApiController { + private readonly IDeviceManager _deviceManager; + private readonly ISessionManager _sessionManager; + /// <summary> - /// Devices Controller. + /// Initializes a new instance of the <see cref="DevicesController"/> class. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] - public class DevicesController : BaseJellyfinApiController + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + public DevicesController( + IDeviceManager deviceManager, + ISessionManager sessionManager) { - private readonly IDeviceManager _deviceManager; - private readonly ISessionManager _sessionManager; + _deviceManager = deviceManager; + _sessionManager = sessionManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="DevicesController"/> class. - /// </summary> - /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> - /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> - public DevicesController( - IDeviceManager deviceManager, - ISessionManager sessionManager) - { - _deviceManager = deviceManager; - _sessionManager = sessionManager; - } + /// <summary> + /// Get Devices. + /// </summary> + /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param> + /// <param name="userId">Gets or sets the user identifier.</param> + /// <response code="200">Devices retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); + } - /// <summary> - /// Get Devices. - /// </summary> - /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param> - /// <param name="userId">Gets or sets the user identifier.</param> - /// <response code="200">Devices retrieved.</response> - /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + /// <summary> + /// Get info for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device info retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Info")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id) + { + var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); + if (deviceInfo is null) { - return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); + return NotFound(); } - /// <summary> - /// Get info for a device. - /// </summary> - /// <param name="id">Device Id.</param> - /// <response code="200">Device info retrieved.</response> - /// <response code="404">Device not found.</response> - /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> - [HttpGet("Info")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id) - { - var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); - if (deviceInfo is null) - { - return NotFound(); - } + return deviceInfo; + } - return deviceInfo; + /// <summary> + /// Get options for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device options retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id) + { + var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); + if (deviceInfo is null) + { + return NotFound(); } - /// <summary> - /// Get options for a device. - /// </summary> - /// <param name="id">Device Id.</param> - /// <response code="200">Device options retrieved.</response> - /// <response code="404">Device not found.</response> - /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> - [HttpGet("Options")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id) - { - var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); - if (deviceInfo is null) - { - return NotFound(); - } + return deviceInfo; + } - return deviceInfo; - } + /// <summary> + /// Update device options. + /// </summary> + /// <param name="id">Device Id.</param> + /// <param name="deviceOptions">Device Options.</param> + /// <response code="204">Device options updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Options")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UpdateDeviceOptions( + [FromQuery, Required] string id, + [FromBody, Required] DeviceOptionsDto deviceOptions) + { + await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Update device options. - /// </summary> - /// <param name="id">Device Id.</param> - /// <param name="deviceOptions">Device Options.</param> - /// <response code="204">Device options updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Options")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> UpdateDeviceOptions( - [FromQuery, Required] string id, - [FromBody, Required] DeviceOptionsDto deviceOptions) + /// <summary> + /// Deletes a device. + /// </summary> + /// <param name="id">Device Id.</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> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id) + { + var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false); + if (existingDevice is null) { - await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false); - return NoContent(); + return NotFound(); } - /// <summary> - /// Deletes a device. - /// </summary> - /// <param name="id">Device Id.</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> - [HttpDelete] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id) - { - var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false); - if (existingDevice is null) - { - return NotFound(); - } - - var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false); - - foreach (var session in sessions.Items) - { - await _sessionManager.Logout(session).ConfigureAwait(false); - } + var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false); - return NoContent(); + foreach (var session in sessions.Items) + { + await _sessionManager.Logout(session).ConfigureAwait(false); } + + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 67cceb4a8c..6f0006832b 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -14,201 +13,200 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Display Preferences Controller. +/// </summary> +[Authorize] +public class DisplayPreferencesController : BaseJellyfinApiController { + private readonly IDisplayPreferencesManager _displayPreferencesManager; + private readonly ILogger<DisplayPreferencesController> _logger; + /// <summary> - /// Display Preferences Controller. + /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class DisplayPreferencesController : BaseJellyfinApiController + /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param> + public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger) { - private readonly IDisplayPreferencesManager _displayPreferencesManager; - private readonly ILogger<DisplayPreferencesController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. - /// </summary> - /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param> - /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param> - public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger) + _displayPreferencesManager = displayPreferencesManager; + _logger = logger; + } + + /// <summary> + /// Get Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User id.</param> + /// <param name="client">Client.</param> + /// <response code="200">Display preferences retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> + [HttpGet("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult<DisplayPreferencesDto> GetDisplayPreferences( + [FromRoute, Required] string displayPreferencesId, + [FromQuery, Required] Guid userId, + [FromQuery, Required] string client) + { + if (!Guid.TryParse(displayPreferencesId, out var itemId)) { - _displayPreferencesManager = displayPreferencesManager; - _logger = logger; + itemId = displayPreferencesId.GetMD5(); } - /// <summary> - /// Get Display Preferences. - /// </summary> - /// <param name="displayPreferencesId">Display preferences id.</param> - /// <param name="userId">User id.</param> - /// <param name="client">Client.</param> - /// <response code="200">Display preferences retrieved.</response> - /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> - [HttpGet("{displayPreferencesId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] - public ActionResult<DisplayPreferencesDto> GetDisplayPreferences( - [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, - [FromQuery, Required] string client) - { - if (!Guid.TryParse(displayPreferencesId, out var itemId)) - { - itemId = displayPreferencesId.GetMD5(); - } + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); + itemPreferences.ItemId = itemId; - var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); - var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); - itemPreferences.ItemId = itemId; + var dto = new DisplayPreferencesDto + { + Client = displayPreferences.Client, + Id = displayPreferences.ItemId.ToString(), + SortBy = itemPreferences.SortBy, + SortOrder = itemPreferences.SortOrder, + IndexBy = displayPreferences.IndexBy?.ToString(), + RememberIndexing = itemPreferences.RememberIndexing, + RememberSorting = itemPreferences.RememberSorting, + ScrollDirection = displayPreferences.ScrollDirection, + ShowBackdrop = displayPreferences.ShowBackdrop, + ShowSidebar = displayPreferences.ShowSidebar + }; + + foreach (var homeSection in displayPreferences.HomeSections) + { + dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); + } - var dto = new DisplayPreferencesDto - { - Client = displayPreferences.Client, - Id = displayPreferences.ItemId.ToString(), - SortBy = itemPreferences.SortBy, - SortOrder = itemPreferences.SortOrder, - IndexBy = displayPreferences.IndexBy?.ToString(), - RememberIndexing = itemPreferences.RememberIndexing, - RememberSorting = itemPreferences.RememberSorting, - ScrollDirection = displayPreferences.ScrollDirection, - ShowBackdrop = displayPreferences.ShowBackdrop, - ShowSidebar = displayPreferences.ShowSidebar - }; - - foreach (var homeSection in displayPreferences.HomeSections) - { - dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); - } + dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant(); + dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; + dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme; - dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant(); - dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture); - dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture); - dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); - dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; - dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme; + // Load all custom display preferences + var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); + foreach (var (key, value) in customDisplayPreferences) + { + dto.CustomPrefs.TryAdd(key, value); + } - // Load all custom display preferences - var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); - foreach (var (key, value) in customDisplayPreferences) - { - dto.CustomPrefs.TryAdd(key, value); - } + // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. + _displayPreferencesManager.SaveChanges(); - // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. - _displayPreferencesManager.SaveChanges(); + return dto; + } - return dto; + /// <summary> + /// Update Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User Id.</param> + /// <param name="client">Client.</param> + /// <param name="displayPreferences">New Display Preferences object.</param> + /// <response code="204">Display preferences updated.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult UpdateDisplayPreferences( + [FromRoute, Required] string displayPreferencesId, + [FromQuery, Required] Guid userId, + [FromQuery, Required] string client, + [FromBody, Required] DisplayPreferencesDto displayPreferences) + { + HomeSectionType[] defaults = + { + HomeSectionType.SmallLibraryTiles, + HomeSectionType.Resume, + HomeSectionType.ResumeAudio, + HomeSectionType.ResumeBook, + HomeSectionType.LiveTv, + HomeSectionType.NextUp, + HomeSectionType.LatestMedia, + HomeSectionType.None, + }; + + if (!Guid.TryParse(displayPreferencesId, out var itemId)) + { + itemId = displayPreferencesId.GetMD5(); } - /// <summary> - /// Update Display Preferences. - /// </summary> - /// <param name="displayPreferencesId">Display preferences id.</param> - /// <param name="userId">User Id.</param> - /// <param name="client">Client.</param> - /// <param name="displayPreferences">New Display Preferences object.</param> - /// <response code="204">Display preferences updated.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("{displayPreferencesId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] - public ActionResult UpdateDisplayPreferences( - [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, - [FromQuery, Required] string client, - [FromBody, Required] DisplayPreferencesDto displayPreferences) + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; + existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; + existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; + + existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection; + existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) + && !string.IsNullOrEmpty(chromecastVersion) + ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) + : ChromecastVersion.Stable; + displayPreferences.CustomPrefs.Remove("chromecastVersion"); + + existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) + || string.IsNullOrEmpty(enableNextVideoInfoOverlay) + || bool.Parse(enableNextVideoInfoOverlay); + displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay"); + + existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) + && !string.IsNullOrEmpty(skipBackLength) + ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) + : 10000; + displayPreferences.CustomPrefs.Remove("skipBackLength"); + + existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) + && !string.IsNullOrEmpty(skipForwardLength) + ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture) + : 30000; + displayPreferences.CustomPrefs.Remove("skipForwardLength"); + + existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme) + ? theme + : string.Empty; + displayPreferences.CustomPrefs.Remove("dashboardTheme"); + + existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home) + ? home + : string.Empty; + displayPreferences.CustomPrefs.Remove("tvhome"); + + existingDisplayPreferences.HomeSections.Clear(); + + foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase))) { - HomeSectionType[] defaults = - { - HomeSectionType.SmallLibraryTiles, - HomeSectionType.Resume, - HomeSectionType.ResumeAudio, - HomeSectionType.ResumeBook, - HomeSectionType.LiveTv, - HomeSectionType.NextUp, - HomeSectionType.LatestMedia, - HomeSectionType.None, - }; - - if (!Guid.TryParse(displayPreferencesId, out var itemId)) + var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture); + if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type)) { - itemId = displayPreferencesId.GetMD5(); + type = order < 8 ? defaults[order] : HomeSectionType.None; } - var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); - existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; - existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; - existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; - - existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection; - existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) - && !string.IsNullOrEmpty(chromecastVersion) - ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) - : ChromecastVersion.Stable; - displayPreferences.CustomPrefs.Remove("chromecastVersion"); - - existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) - || string.IsNullOrEmpty(enableNextVideoInfoOverlay) - || bool.Parse(enableNextVideoInfoOverlay); - displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay"); - - existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) - && !string.IsNullOrEmpty(skipBackLength) - ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) - : 10000; - displayPreferences.CustomPrefs.Remove("skipBackLength"); - - existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) - && !string.IsNullOrEmpty(skipForwardLength) - ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture) - : 30000; - displayPreferences.CustomPrefs.Remove("skipForwardLength"); - - existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme) - ? theme - : string.Empty; - displayPreferences.CustomPrefs.Remove("dashboardTheme"); - - existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home) - ? home - : string.Empty; - displayPreferences.CustomPrefs.Remove("tvhome"); - - existingDisplayPreferences.HomeSections.Clear(); - - foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase))) - { - var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture); - if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type)) - { - type = order < 8 ? defaults[order] : HomeSectionType.None; - } - - displayPreferences.CustomPrefs.Remove(key); - existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type }); - } + displayPreferences.CustomPrefs.Remove(key); + existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type }); + } - foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) + foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) + { + if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type)) { - if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type)) - { - _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); - displayPreferences.CustomPrefs.Remove(key); - } + _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); + displayPreferences.CustomPrefs.Remove(key); } + } - var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client); - itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName"; - itemPrefs.SortOrder = displayPreferences.SortOrder; - itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; - itemPrefs.RememberSorting = displayPreferences.RememberSorting; - itemPrefs.ItemId = itemId; + var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client); + itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName"; + itemPrefs.SortOrder = displayPreferences.SortOrder; + itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; + itemPrefs.RememberSorting = displayPreferences.RememberSorting; + itemPrefs.ItemId = itemId; - // Set all remaining custom preferences. - _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); - _displayPreferencesManager.SaveChanges(); + // Set all remaining custom preferences. + _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); + _displayPreferencesManager.SaveChanges(); - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DlnaController.cs b/Jellyfin.Api/Controllers/DlnaController.cs index 07e0590a10..415385463d 100644 --- a/Jellyfin.Api/Controllers/DlnaController.cs +++ b/Jellyfin.Api/Controllers/DlnaController.cs @@ -7,127 +7,126 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Dlna Controller. +/// </summary> +[Authorize(Policy = Policies.RequiresElevation)] +public class DlnaController : BaseJellyfinApiController { + private readonly IDlnaManager _dlnaManager; + /// <summary> - /// Dlna Controller. + /// Initializes a new instance of the <see cref="DlnaController"/> class. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] - public class DlnaController : BaseJellyfinApiController + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public DlnaController(IDlnaManager dlnaManager) { - private readonly IDlnaManager _dlnaManager; + _dlnaManager = dlnaManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="DlnaController"/> class. - /// </summary> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - public DlnaController(IDlnaManager dlnaManager) - { - _dlnaManager = dlnaManager; - } + /// <summary> + /// Get profile infos. + /// </summary> + /// <response code="200">Device profile infos returned.</response> + /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns> + [HttpGet("ProfileInfos")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos() + { + return Ok(_dlnaManager.GetProfileInfos()); + } - /// <summary> - /// Get profile infos. - /// </summary> - /// <response code="200">Device profile infos returned.</response> - /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns> - [HttpGet("ProfileInfos")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos() - { - return Ok(_dlnaManager.GetProfileInfos()); - } + /// <summary> + /// Gets the default profile. + /// </summary> + /// <response code="200">Default device profile returned.</response> + /// <returns>An <see cref="OkResult"/> containing the default profile.</returns> + [HttpGet("Profiles/Default")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<DeviceProfile> GetDefaultProfile() + { + return _dlnaManager.GetDefaultProfile(); + } - /// <summary> - /// Gets the default profile. - /// </summary> - /// <response code="200">Default device profile returned.</response> - /// <returns>An <see cref="OkResult"/> containing the default profile.</returns> - [HttpGet("Profiles/Default")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<DeviceProfile> GetDefaultProfile() + /// <summary> + /// Gets a single profile. + /// </summary> + /// <param name="profileId">Profile Id.</param> + /// <response code="200">Device profile returned.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns> + [HttpGet("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId) + { + var profile = _dlnaManager.GetProfile(profileId); + if (profile is null) { - return _dlnaManager.GetDefaultProfile(); + return NotFound(); } - /// <summary> - /// Gets a single profile. - /// </summary> - /// <param name="profileId">Profile Id.</param> - /// <response code="200">Device profile returned.</response> - /// <response code="404">Device profile not found.</response> - /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns> - [HttpGet("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId) - { - var profile = _dlnaManager.GetProfile(profileId); - if (profile is null) - { - return NotFound(); - } + return profile; + } - return profile; + /// <summary> + /// Deletes a profile. + /// </summary> + /// <param name="profileId">Profile id.</param> + /// <response code="204">Device profile deleted.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> + [HttpDelete("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteProfile([FromRoute, Required] string profileId) + { + var existingDeviceProfile = _dlnaManager.GetProfile(profileId); + if (existingDeviceProfile is null) + { + return NotFound(); } - /// <summary> - /// Deletes a profile. - /// </summary> - /// <param name="profileId">Profile id.</param> - /// <response code="204">Device profile deleted.</response> - /// <response code="404">Device profile not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> - [HttpDelete("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteProfile([FromRoute, Required] string profileId) - { - var existingDeviceProfile = _dlnaManager.GetProfile(profileId); - if (existingDeviceProfile is null) - { - return NotFound(); - } + _dlnaManager.DeleteProfile(profileId); + return NoContent(); + } - _dlnaManager.DeleteProfile(profileId); - return NoContent(); - } + /// <summary> + /// Creates a profile. + /// </summary> + /// <param name="deviceProfile">Device profile.</param> + /// <response code="204">Device profile created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Profiles")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile) + { + _dlnaManager.CreateProfile(deviceProfile); + return NoContent(); + } - /// <summary> - /// Creates a profile. - /// </summary> - /// <param name="deviceProfile">Device profile.</param> - /// <response code="204">Device profile created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Profiles")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile) + /// <summary> + /// Updates a profile. + /// </summary> + /// <param name="profileId">Profile id.</param> + /// <param name="deviceProfile">Device profile.</param> + /// <response code="204">Device profile updated.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> + [HttpPost("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile) + { + var existingDeviceProfile = _dlnaManager.GetProfile(profileId); + if (existingDeviceProfile is null) { - _dlnaManager.CreateProfile(deviceProfile); - return NoContent(); + return NotFound(); } - /// <summary> - /// Updates a profile. - /// </summary> - /// <param name="profileId">Profile id.</param> - /// <param name="deviceProfile">Device profile.</param> - /// <response code="204">Device profile updated.</response> - /// <response code="404">Device profile not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> - [HttpPost("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile) - { - var existingDeviceProfile = _dlnaManager.GetProfile(profileId); - if (existingDeviceProfile is null) - { - return NotFound(); - } - - _dlnaManager.UpdateProfile(profileId, deviceProfile); - return NoContent(); - } + _dlnaManager.UpdateProfile(profileId, deviceProfile); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 96c492b3ec..95b296fae9 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -14,311 +14,310 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Dlna Server Controller. +/// </summary> +[Route("Dlna")] +[DlnaEnabled] +[Authorize(Policy = Policies.AnonymousLanAccessPolicy)] +public class DlnaServerController : BaseJellyfinApiController { + private readonly IDlnaManager _dlnaManager; + private readonly IContentDirectory _contentDirectory; + private readonly IConnectionManager _connectionManager; + private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar; + /// <summary> - /// Dlna Server Controller. + /// Initializes a new instance of the <see cref="DlnaServerController"/> class. /// </summary> - [Route("Dlna")] - [DlnaEnabled] - [Authorize(Policy = Policies.AnonymousLanAccessPolicy)] - public class DlnaServerController : BaseJellyfinApiController + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public DlnaServerController(IDlnaManager dlnaManager) { - private readonly IDlnaManager _dlnaManager; - private readonly IContentDirectory _contentDirectory; - private readonly IConnectionManager _connectionManager; - private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar; + _dlnaManager = dlnaManager; + _contentDirectory = DlnaEntryPoint.Current.ContentDirectory; + _connectionManager = DlnaEntryPoint.Current.ConnectionManager; + _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar; + } - /// <summary> - /// Initializes a new instance of the <see cref="DlnaServerController"/> class. - /// </summary> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - public DlnaServerController(IDlnaManager dlnaManager) - { - _dlnaManager = dlnaManager; - _contentDirectory = DlnaEntryPoint.Current.ContentDirectory; - _connectionManager = DlnaEntryPoint.Current.ConnectionManager; - _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar; - } + /// <summary> + /// Get Description Xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Description xml returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> + [HttpGet("{serverId}/description")] + [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId) + { + var url = GetAbsoluteUri(); + var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); + var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress); + return Ok(xml); + } - /// <summary> - /// Get Description Xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Description xml returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> - [HttpGet("{serverId}/description")] - [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId) - { - var url = GetAbsoluteUri(); - var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); - var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress); - return Ok(xml); - } + /// <summary> + /// Gets Dlna content directory xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna content directory returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns> + [HttpGet("{serverId}/ContentDirectory")] + [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")] + [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId) + { + return Ok(_contentDirectory.GetServiceXml()); + } - /// <summary> - /// Gets Dlna content directory xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Dlna content directory returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns> - [HttpGet("{serverId}/ContentDirectory")] - [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")] - [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId) - { - return Ok(_contentDirectory.GetServiceXml()); - } + /// <summary> + /// Gets Dlna media receiver registrar xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Dlna media receiver registrar xml.</returns> + [HttpGet("{serverId}/MediaReceiverRegistrar")] + [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] + [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId) + { + return Ok(_mediaReceiverRegistrar.GetServiceXml()); + } - /// <summary> - /// Gets Dlna media receiver registrar xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Dlna media receiver registrar xml returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Dlna media receiver registrar xml.</returns> - [HttpGet("{serverId}/MediaReceiverRegistrar")] - [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] - [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId) - { - return Ok(_mediaReceiverRegistrar.GetServiceXml()); - } + /// <summary> + /// Gets Dlna media receiver registrar xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Dlna media receiver registrar xml.</returns> + [HttpGet("{serverId}/ConnectionManager")] + [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] + [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId) + { + return Ok(_connectionManager.GetServiceXml()); + } - /// <summary> - /// Gets Dlna media receiver registrar xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Dlna media receiver registrar xml returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Dlna media receiver registrar xml.</returns> - [HttpGet("{serverId}/ConnectionManager")] - [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] - [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId) - { - return Ok(_connectionManager.GetServiceXml()); - } + /// <summary> + /// Process a content directory control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/ContentDirectory/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + } - /// <summary> - /// Process a content directory control request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Control response.</returns> - [HttpPost("{serverId}/ContentDirectory/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); - } + /// <summary> + /// Process a connection manager control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/ConnectionManager/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + } - /// <summary> - /// Process a connection manager control request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Control response.</returns> - [HttpPost("{serverId}/ConnectionManager/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); - } + /// <summary> + /// Process a media receiver registrar control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + } - /// <summary> - /// Process a media receiver registrar control request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Control response.</returns> - [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); - } + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Event subscription response.</returns> + [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] + [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] + [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) + { + return ProcessEventRequest(_mediaReceiverRegistrar); + } - /// <summary> - /// Processes an event subscription request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Event subscription response.</returns> - [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] - [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] - [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) - { - return ProcessEventRequest(_mediaReceiverRegistrar); - } + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Event subscription response.</returns> + [HttpSubscribe("{serverId}/ContentDirectory/Events")] + [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] + [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) + { + return ProcessEventRequest(_contentDirectory); + } - /// <summary> - /// Processes an event subscription request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Event subscription response.</returns> - [HttpSubscribe("{serverId}/ContentDirectory/Events")] - [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] - [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) - { - return ProcessEventRequest(_contentDirectory); - } + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Event subscription response.</returns> + [HttpSubscribe("{serverId}/ConnectionManager/Events")] + [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] + [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) + { + return ProcessEventRequest(_connectionManager); + } - /// <summary> - /// Processes an event subscription request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Event subscription response.</returns> - [HttpSubscribe("{serverId}/ConnectionManager/Events")] - [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] - [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) - { - return ProcessEventRequest(_connectionManager); - } + /// <summary> + /// Gets a server icon. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <param name="fileName">The icon filename.</param> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Icon stream.</returns> + [HttpGet("{serverId}/icons/{fileName}")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [ProducesImageFile] + public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName) + { + return GetIconInternal(fileName); + } - /// <summary> - /// Gets a server icon. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <param name="fileName">The icon filename.</param> - /// <response code="200">Request processed.</response> - /// <response code="404">Not Found.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Icon stream.</returns> - [HttpGet("{serverId}/icons/{fileName}")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [ProducesImageFile] - public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName) - { - return GetIconInternal(fileName); - } + /// <summary> + /// Gets a server icon. + /// </summary> + /// <param name="fileName">The icon filename.</param> + /// <returns>Icon stream.</returns> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> + [HttpGet("icons/{fileName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [ProducesImageFile] + public ActionResult GetIcon([FromRoute, Required] string fileName) + { + return GetIconInternal(fileName); + } - /// <summary> - /// Gets a server icon. - /// </summary> - /// <param name="fileName">The icon filename.</param> - /// <returns>Icon stream.</returns> - /// <response code="200">Request processed.</response> - /// <response code="404">Not Found.</response> - /// <response code="503">DLNA is disabled.</response> - [HttpGet("icons/{fileName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [ProducesImageFile] - public ActionResult GetIcon([FromRoute, Required] string fileName) + private ActionResult GetIconInternal(string fileName) + { + var icon = _dlnaManager.GetIcon(fileName); + if (icon is null) { - return GetIconInternal(fileName); + return NotFound(); } - private ActionResult GetIconInternal(string fileName) - { - var icon = _dlnaManager.GetIcon(fileName); - if (icon is null) - { - return NotFound(); - } + return File(icon.Stream, MimeTypes.GetMimeType(fileName)); + } - return File(icon.Stream, MimeTypes.GetMimeType(fileName)); - } + private string GetAbsoluteUri() + { + return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; + } - private string GetAbsoluteUri() + private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) + { + return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers) { - return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; - } + InputXml = requestStream, + TargetServerUuId = id, + RequestedUrl = GetAbsoluteUri() + }); + } - private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) + private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager) + { + var subscriptionId = Request.Headers["SID"]; + if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase)) { - return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers) - { - InputXml = requestStream, - TargetServerUuId = id, - RequestedUrl = GetAbsoluteUri() - }); - } + var notificationType = Request.Headers["NT"]; + var callback = Request.Headers["CALLBACK"]; + var timeoutString = Request.Headers["TIMEOUT"]; - private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager) - { - var subscriptionId = Request.Headers["SID"]; - if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(notificationType)) { - var notificationType = Request.Headers["NT"]; - var callback = Request.Headers["CALLBACK"]; - var timeoutString = Request.Headers["TIMEOUT"]; - - if (string.IsNullOrEmpty(notificationType)) - { - return dlnaEventManager.RenewEventSubscription( - subscriptionId, - notificationType, - timeoutString, - callback); - } - - return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback); + return dlnaEventManager.RenewEventSubscription( + subscriptionId, + notificationType, + timeoutString, + callback); } - return dlnaEventManager.CancelEventSubscription(subscriptionId); + return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback); } + + return dlnaEventManager.CancelEventSubscription(subscriptionId); } } diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index b41e239255..ce684e457c 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -9,10 +9,11 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using Jellyfin.MediaEncoding.Hls.Playlist; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; @@ -20,6 +21,7 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -30,2026 +32,2075 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Dynamic hls controller. +/// </summary> +[Route("")] +[Authorize] +public class DynamicHlsController : BaseJellyfinApiController { + private const string DefaultVodEncoderPreset = "veryfast"; + private const string DefaultEventEncoderPreset = "superfast"; + private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDlnaManager _dlnaManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ILogger<DynamicHlsController> _logger; + private readonly EncodingHelper _encodingHelper; + private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator; + private readonly DynamicHlsHelper _dynamicHlsHelper; + private readonly EncodingOptions _encodingOptions; + /// <summary> - /// Dynamic hls controller. + /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class DynamicHlsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param> + /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param> + public DynamicHlsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + ILogger<DynamicHlsController> logger, + DynamicHlsHelper dynamicHlsHelper, + EncodingHelper encodingHelper, + IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator) { - private const string DefaultVodEncoderPreset = "veryfast"; - private const string DefaultEventEncoderPreset = "superfast"; - private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; - - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDlnaManager _dlnaManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly ILogger<DynamicHlsController> _logger; - private readonly EncodingHelper _encodingHelper; - private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator; - private readonly DynamicHlsHelper _dynamicHlsHelper; - private readonly EncodingOptions _encodingOptions; - - /// <summary> - /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> - /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param> - /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param> - public DynamicHlsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - ILogger<DynamicHlsController> logger, - DynamicHlsHelper dynamicHlsHelper, - EncodingHelper encodingHelper, - IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dlnaManager = dlnaManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _logger = logger; - _dynamicHlsHelper = dynamicHlsHelper; - _encodingHelper = encodingHelper; - _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator; - - _encodingOptions = serverConfigurationManager.GetEncodingOptions(); - } + _libraryManager = libraryManager; + _userManager = userManager; + _dlnaManager = dlnaManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _logger = logger; + _dynamicHlsHelper = dynamicHlsHelper; + _encodingHelper = encodingHelper; + _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator; + + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); + } - /// <summary> - /// Gets a hls live stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The audio container.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <param name="maxWidth">Optional. The max width.</param> - /// <param name="maxHeight">Optional. The max height.</param> - /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> - /// <response code="200">Hls live stream retrieved.</response> - /// <returns>A <see cref="FileResult"/> containing the hls file.</returns> - [HttpGet("Videos/{itemId}/live.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task<ActionResult> GetLiveHlsStream( - [FromRoute, Required] Guid itemId, - [FromQuery] string? container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? enableSubtitlesInManifest) + /// <summary> + /// Gets a hls live stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="maxWidth">Optional. The max width.</param> + /// <param name="maxHeight">Optional. The max height.</param> + /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> + /// <response code="200">Hls live stream retrieved.</response> + /// <returns>A <see cref="FileResult"/> containing the hls file.</returns> + [HttpGet("Videos/{itemId}/live.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task<ActionResult> GetLiveHlsStream( + [FromRoute, Required] Guid itemId, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? enableSubtitlesInManifest) + { + VideoRequestDto streamingRequest = new VideoRequestDto { - VideoRequestDto streamingRequest = new VideoRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true - }; - - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token - // since it gets disposed when ffmpeg exits - var cancellationToken = cancellationTokenSource.Token; - var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - TranscodingJobType, - cancellationToken) - .ConfigureAwait(false); - - TranscodingJobDto? job = null; - var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - - if (!System.IO.File.Exists(playlistPath)) + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true + }; + + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token + // since it gets disposed when ffmpeg exits + var cancellationToken = cancellationTokenSource.Token; + var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationToken) + .ConfigureAwait(false); + + TranscodingJobDto? job = null; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + + if (!System.IO.File.Exists(playlistPath)) + { + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + if (!System.IO.File.Exists(playlistPath)) { - if (!System.IO.File.Exists(playlistPath)) + // If the playlist doesn't already exist, startup ffmpeg + try { - // If the playlist doesn't already exist, startup ffmpeg - try - { - job = await _transcodingJobHelper.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state, true, 0), - Request, - TranscodingJobType, - cancellationTokenSource) - .ConfigureAwait(false); - job.IsLiveOutput = true; - } - catch - { - state.Dispose(); - throw; - } + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, true, 0), + Request, + TranscodingJobType, + cancellationTokenSource) + .ConfigureAwait(false); + job.IsLiveOutput = true; + } + catch + { + state.Dispose(); + throw; + } - minSegments = state.MinSegments; - if (minSegments > 0) - { - await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false); - } + minSegments = state.MinSegments; + if (minSegments > 0) + { + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false); } } - finally - { - transcodingLock.Release(); - } } - - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - - if (job is not null) + finally { - _transcodingJobHelper.OnTranscodeEndRequest(job); + transcodingLock.Release(); } - - var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); - - return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); } - /// <summary> - /// Gets a video hls playlist stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> - [HttpGet("Videos/{itemId}/master.m3u8")] - [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task<ActionResult> GetMasterHlsVideoPlaylist( - [FromRoute, Required] Guid itemId, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery, Required] string mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true) - { - var streamingRequest = new HlsVideoRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions, - EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming - }; + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + if (job is not null) + { + _transcodingJobHelper.OnTranscodeEndRequest(job); } - /// <summary> - /// Gets an audio hls playlist stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> - /// <response code="200">Audio stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> - [HttpGet("Audio/{itemId}/master.m3u8")] - [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task<ActionResult> GetMasterHlsAudioPlaylist( - [FromRoute, Required] Guid itemId, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery, Required] string mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true) - { - var streamingRequest = new HlsAudioRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions, - EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming - }; + var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); - } + return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); + } - /// <summary> - /// Gets a video stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("Videos/{itemId}/main.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task<ActionResult> GetVariantHlsVideoPlaylist( - [FromRoute, Required] Guid itemId, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets a video hls playlist stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> + [HttpGet("Videos/{itemId}/master.m3u8")] + [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task<ActionResult> GetMasterHlsVideoPlaylist( + [FromRoute, Required] Guid itemId, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery, Required] string mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAdaptiveBitrateStreaming = true) + { + var streamingRequest = new HlsVideoRequestDto { - using var cancellationTokenSource = new CancellationTokenSource(); - var streamingRequest = new VideoRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; - - return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) - .ConfigureAwait(false); - } + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions, + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + }; + + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + } - /// <summary> - /// Gets an audio stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Audio stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("Audio/{itemId}/main.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task<ActionResult> GetVariantHlsAudioPlaylist( - [FromRoute, Required] Guid itemId, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets an audio hls playlist stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> + [HttpGet("Audio/{itemId}/master.m3u8")] + [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task<ActionResult> GetMasterHlsAudioPlaylist( + [FromRoute, Required] Guid itemId, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery, Required] string mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAdaptiveBitrateStreaming = true) + { + var streamingRequest = new HlsAudioRequestDto { - using var cancellationTokenSource = new CancellationTokenSource(); - var streamingRequest = new StreamingRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions, + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + }; + + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + } - return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) - .ConfigureAwait(false); - } + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Videos/{itemId}/main.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task<ActionResult> GetVariantHlsVideoPlaylist( + [FromRoute, Required] Guid itemId, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + using var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new VideoRequestDto + { + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) + .ConfigureAwait(false); + } - /// <summary> - /// Gets a video stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <param name="segmentId">The segment id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> - /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The desired segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesVideoFile] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> GetHlsVideoSegment( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string playlistId, - [FromRoute, Required] int segmentId, - [FromRoute, Required] string container, - [FromQuery, Required] long runtimeTicks, - [FromQuery, Required] long actualSegmentLengthTicks, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets an audio stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Audio/{itemId}/main.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task<ActionResult> GetVariantHlsAudioPlaylist( + [FromRoute, Required] Guid itemId, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + using var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new StreamingRequestDto { - var streamingRequest = new VideoRequestDto - { - Id = itemId, - CurrentRuntimeTicks = runtimeTicks, - ActualSegmentLengthTicks = actualSegmentLengthTicks, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) + .ConfigureAwait(false); + } - return await GetDynamicSegment(streamingRequest, segmentId) - .ConfigureAwait(false); - } + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> + /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The desired segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> GetHlsVideoSegment( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string playlistId, + [FromRoute, Required] int segmentId, + [FromRoute, Required] string container, + [FromQuery, Required] long runtimeTicks, + [FromQuery, Required] long actualSegmentLengthTicks, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var streamingRequest = new VideoRequestDto + { + Id = itemId, + CurrentRuntimeTicks = runtimeTicks, + ActualSegmentLengthTicks = actualSegmentLengthTicks, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetDynamicSegment(streamingRequest, segmentId) + .ConfigureAwait(false); + } - /// <summary> - /// Gets a video stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <param name="segmentId">The segment id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> - /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> GetHlsAudioSegment( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string playlistId, - [FromRoute, Required] int segmentId, - [FromRoute, Required] string container, - [FromQuery, Required] long runtimeTicks, - [FromQuery, Required] long actualSegmentLengthTicks, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> + /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> GetHlsAudioSegment( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string playlistId, + [FromRoute, Required] int segmentId, + [FromRoute, Required] string container, + [FromQuery, Required] long runtimeTicks, + [FromQuery, Required] long actualSegmentLengthTicks, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var streamingRequest = new StreamingRequestDto { - var streamingRequest = new StreamingRequestDto - { - Id = itemId, - Container = container, - CurrentRuntimeTicks = runtimeTicks, - ActualSegmentLengthTicks = actualSegmentLengthTicks, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; + Id = itemId, + Container = container, + CurrentRuntimeTicks = runtimeTicks, + ActualSegmentLengthTicks = actualSegmentLengthTicks, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetDynamicSegment(streamingRequest, segmentId) + .ConfigureAwait(false); + } - return await GetDynamicSegment(streamingRequest, segmentId) - .ConfigureAwait(false); - } + private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource) + { + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + var request = new CreateMainPlaylistRequest( + state.MediaPath, + state.SegmentLength * 1000, + state.RunTimeTicks ?? 0, + state.Request.SegmentContainer ?? string.Empty, + "hls1/main/", + Request.QueryString.ToString(), + EncodingHelper.IsCopyCodec(state.OutputVideoCodec)); + var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request); + + return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8")); + } - private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource) + private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId) + { + if ((streamingRequest.StartTimeTicks ?? 0) > 0) { - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - TranscodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - var request = new CreateMainPlaylistRequest( - state.MediaPath, - state.SegmentLength * 1000, - state.RunTimeTicks ?? 0, - state.Request.SegmentContainer ?? string.Empty, - "hls1/main/", - Request.QueryString.ToString(), - EncodingHelper.IsCopyCodec(state.OutputVideoCodec)); - var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request); - - return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8")); + throw new ArgumentException("StartTimeTicks is not allowed."); } - private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId) - { - if ((streamingRequest.StartTimeTicks ?? 0) > 0) - { - throw new ArgumentException("StartTimeTicks is not allowed."); - } + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationToken) + .ConfigureAwait(false); - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - var cancellationToken = cancellationTokenSource.Token; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - TranscodingJobType, - cancellationToken) - .ConfigureAwait(false); + var segmentPath = GetSegmentPath(state, playlistPath, segmentId); - var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - var segmentPath = GetSegmentPath(state, playlistPath, segmentId); + TranscodingJobDto? job; - var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + if (System.IO.File.Exists(segmentPath)) + { + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } - TranscodingJobDto? job; + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); + var released = false; + var startTranscoding = false; + try + { if (System.IO.File.Exists(segmentPath)) { job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); + transcodingLock.Release(); + released = true; + _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - var released = false; - var startTranscoding = false; - - try + else { - if (System.IO.File.Exists(segmentPath)) + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; + + if (segmentId == -1) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - transcodingLock.Release(); - released = true; - _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; } - else + else if (currentTranscodingIndex is null) { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (segmentId == -1) - { - _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); - startTranscoding = true; - segmentId = 0; - } - else if (currentTranscodingIndex is null) - { - _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); - startTranscoding = true; - } - else if (segmentId < currentTranscodingIndex.Value) - { - _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); - startTranscoding = true; - } - else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) - { - _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); - startTranscoding = true; - } + _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); + startTranscoding = true; + } + else if (segmentId < currentTranscodingIndex.Value) + { + _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); + startTranscoding = true; + } + else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) + { + _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); + startTranscoding = true; + } - if (startTranscoding) + if (startTranscoding) + { + // If the playlist doesn't already exist, startup ffmpeg + try { - // If the playlist doesn't already exist, startup ffmpeg - try - { - await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) - .ConfigureAwait(false); + await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + .ConfigureAwait(false); - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } - - streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; - - state.WaitForPath = segmentPath; - job = await _transcodingJobHelper.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state, false, segmentId), - Request, - TranscodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } - catch + if (currentTranscodingIndex.HasValue) { - state.Dispose(); - throw; + DeleteLastFile(playlistPath, segmentExtension, 0); } - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + + state.WaitForPath = segmentPath; + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, false, segmentId), + Request, + TranscodingJobType, + cancellationTokenSource).ConfigureAwait(false); } - else + catch { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job?.TranscodingThrottler is not null) - { - await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); - } + state.Dispose(); + throw; } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); } - } - finally - { - if (!released) + else { - transcodingLock.Release(); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job?.TranscodingThrottler is not null) + { + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); + } } } - - _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - - private static double[] GetSegmentLengths(StreamState state) - => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); - - internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + finally { - var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks; - var wholeSegments = runtimeTicks / segmentLengthTicks; - var remainingTicks = runtimeTicks % segmentLengthTicks; - - var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1); - var segments = new double[segmentsLen]; - for (int i = 0; i < wholeSegments; i++) + if (!released) { - segments[i] = segmentlength; + transcodingLock.Release(); } + } - if (remainingTicks != 0) - { - segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; - } + _logger.LogDebug("returning {0} [general case]", segmentPath); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } + + private static double[] GetSegmentLengths(StreamState state) + => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); + + internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + { + var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks; + var wholeSegments = runtimeTicks / segmentLengthTicks; + var remainingTicks = runtimeTicks % segmentLengthTicks; - return segments; + var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1); + var segments = new double[segmentsLen]; + for (int i = 0; i < wholeSegments; i++) + { + segments[i] = segmentlength; } - private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber) + if (remainingTicks != 0) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); + segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; + } - if (state.BaseRequest.BreakOnNonKeyFrames) - { - // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe - // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable - // to produce a missing part of video stream before first keyframe is encountered, which may lead to - // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js - _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); - state.BaseRequest.BreakOnNonKeyFrames = false; - } + return segments; + } - var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; + private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); - var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); - var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - var outputTsArg = outputPrefix + "%d" + outputExtension; + if (state.BaseRequest.BreakOnNonKeyFrames) + { + // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe + // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable + // to produce a missing part of video stream before first keyframe is encountered, which may lead to + // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js + _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); + state.BaseRequest.BreakOnNonKeyFrames = false; + } - var segmentFormat = string.Empty; - var segmentContainer = outputExtension.TrimStart('.'); - var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer); + var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase)) - { - segmentFormat = "mpegts"; - } - else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)) - { - var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch - { - // on Windows, the path of fmp4 header file needs to be configured - true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"", - // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder - false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"" - }; + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + var outputTsArg = outputPrefix + "%d" + outputExtension; - segmentFormat = "fmp4" + outputFmp4HeaderArg; - } - else + var segmentFormat = string.Empty; + var segmentContainer = outputExtension.TrimStart('.'); + var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer); + + if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase)) + { + segmentFormat = "mpegts"; + } + else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch { - _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer); - segmentFormat = "mpegts"; - } + // on Windows, the path of fmp4 header file needs to be configured + true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"", + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"" + }; - var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 - ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) - : "128"; + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer); + segmentFormat = "mpegts"; + } - var baseUrlParam = string.Empty; - if (isEventPlaylist) - { - baseUrlParam = string.Format( - CultureInfo.InvariantCulture, - " -hls_base_url \"hls/{0}/\"", - Path.GetFileNameWithoutExtension(outputPath)); - } + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + : "128"; - return string.Format( + var baseUrlParam = string.Empty; + if (isEventPlaylist) + { + baseUrlParam = string.Format( CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"", - inputModifier, - _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer), - threads, - mapArgs, - GetVideoArguments(state, startNumber, isEventPlaylist), - GetAudioArguments(state), - maxMuxingQueueSize, - state.SegmentLength.ToString(CultureInfo.InvariantCulture), - segmentFormat, - startNumber.ToString(CultureInfo.InvariantCulture), - baseUrlParam, - isEventPlaylist ? "event" : "vod", - outputTsArg, - outputPath).Trim(); + " -hls_base_url \"hls/{0}/\"", + Path.GetFileNameWithoutExtension(outputPath)); } - /// <summary> - /// Gets the audio arguments for transcoding. - /// </summary> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <returns>The command line arguments for audio transcoding.</returns> - private string GetAudioArguments(StreamState state) + var hlsArguments = GetHlsArguments(isEventPlaylist, state.SegmentLength); + + return string.Format( + CultureInfo.InvariantCulture, + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{11}\" {12} -y \"{13}\"", + inputModifier, + _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer), + threads, + mapArgs, + GetVideoArguments(state, startNumber, isEventPlaylist), + GetAudioArguments(state), + maxMuxingQueueSize, + state.SegmentLength.ToString(CultureInfo.InvariantCulture), + segmentFormat, + startNumber.ToString(CultureInfo.InvariantCulture), + baseUrlParam, + EncodingUtils.NormalizePath(outputTsArg), + hlsArguments, + EncodingUtils.NormalizePath(outputPath)).Trim(); + } + + /// <summary> + /// Gets the HLS arguments for transcoding. + /// </summary> + /// <returns>The command line arguments for HLS transcoding.</returns> + private string GetHlsArguments(bool isEventPlaylist, int segmentLength) + { + var enableThrottling = _encodingOptions.EnableThrottling; + var enableSegmentDeletion = _encodingOptions.EnableSegmentDeletion; + + // Only enable segment deletion when throttling is enabled + if (enableThrottling && enableSegmentDeletion) { - if (state.AudioStream is null) - { - return string.Empty; - } + // Store enough segments for configured seconds of playback; this needs to be above throttling settings + var segmentCount = _encodingOptions.SegmentKeepSeconds / segmentLength; + + _logger.LogDebug("Using throttling and segment deletion, keeping {0} segments", segmentCount); - var audioCodec = _encodingHelper.GetAudioEncoder(state); + return string.Format(CultureInfo.InvariantCulture, "-hls_list_size {0} -hls_flags delete_segments", segmentCount.ToString(CultureInfo.InvariantCulture)); + } + else + { + _logger.LogDebug("Using normal playback, is event playlist? {0}", isEventPlaylist); + + return string.Format(CultureInfo.InvariantCulture, "-hls_playlist_type {0} -hls_list_size 0", isEventPlaylist ? "event" : "vod"); + } + } + + /// <summary> + /// Gets the audio arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The command line arguments for audio transcoding.</returns> + private string GetAudioArguments(StreamState state) + { + if (state.AudioStream is null) + { + return string.Empty; + } + + var audioCodec = _encodingHelper.GetAudioEncoder(state); - if (!state.IsOutputVideo) + if (!state.IsOutputVideo) + { + if (EncodingHelper.IsCopyCodec(audioCodec)) { - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - return "-acodec copy -strict -2" + bitStreamArgs; - } + return "-acodec copy -strict -2" + bitStreamArgs; + } - var audioTranscodeParams = string.Empty; + var audioTranscodeParams = string.Empty; - audioTranscodeParams += "-acodec " + audioCodec; + audioTranscodeParams += "-acodec " + audioCodec; - if (state.OutputAudioBitrate.HasValue) - { - audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); - } + var audioBitrate = state.OutputAudioBitrate; + var audioChannels = state.OutputAudioChannels; - if (state.OutputAudioChannels.HasValue) + if (audioBitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(state.ActualOutputAudioCodec, StringComparison.OrdinalIgnoreCase)) + { + var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, audioBitrate.Value / (audioChannels ?? 2)); + if (_encodingOptions.EnableAudioVbr && vbrParam is not null) { - audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); + audioTranscodeParams += vbrParam; } - - if (state.OutputAudioSampleRate.HasValue) + else { - audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + audioTranscodeParams += " -ab " + audioBitrate.Value.ToString(CultureInfo.InvariantCulture); } - - audioTranscodeParams += " -vn"; - return audioTranscodeParams; } - // dts, flac, opus and truehd are experimental in mp4 muxer - var strictArgs = string.Empty; - - if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) + if (audioChannels.HasValue) { - strictArgs = " -strict -2"; + audioTranscodeParams += " -ac " + audioChannels.Value.ToString(CultureInfo.InvariantCulture); } - if (EncodingHelper.IsCopyCodec(audioCodec)) + if (state.OutputAudioSampleRate.HasValue) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; - - if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) - { - return copyArgs + " -copypriorss:a:0 0"; - } - - return copyArgs; + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - var args = "-codec:a:0 " + audioCodec + strictArgs; + audioTranscodeParams += " -vn"; + return audioTranscodeParams; + } - var channels = state.OutputAudioChannels; + // dts, flac, opus and truehd are experimental in mp4 muxer + var strictArgs = string.Empty; + var actualOutputAudioCodec = state.ActualOutputAudioCodec; + if (string.Equals(actualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) + { + strictArgs = " -strict -2"; + } - if (channels.HasValue - && (channels.Value != 2 - || (state.AudioStream is not null - && state.AudioStream.Channels.HasValue - && state.AudioStream.Channels.Value > 5 - && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; + + if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) { - args += " -ac " + channels.Value; + return copyArgs + " -copypriorss:a:0 0"; } - var bitrate = state.OutputAudioBitrate; + return copyArgs; + } + + var args = "-codec:a:0 " + audioCodec + strictArgs; + + var channels = state.OutputAudioChannels; - if (bitrate.HasValue) + if (channels.HasValue + && (channels.Value != 2 + || (state.AudioStream is not null + && state.AudioStream.Channels.HasValue + && state.AudioStream.Channels.Value > 5 + && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) + { + args += " -ac " + channels.Value; + } + + var bitrate = state.OutputAudioBitrate; + if (bitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(actualOutputAudioCodec, StringComparison.OrdinalIgnoreCase)) + { + var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, bitrate.Value / (channels ?? 2)); + if (_encodingOptions.EnableAudioVbr && vbrParam is not null) { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + args += vbrParam; } - - if (state.OutputAudioSampleRate.HasValue) + else { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); } + } + + if (state.OutputAudioSampleRate.HasValue) + { + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } + + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); + + return args; + } - args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); + /// <summary> + /// Gets the video arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="startNumber">The first number in the hls sequence.</param> + /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param> + /// <returns>The command line arguments for video transcoding.</returns> + private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) + { + if (state.VideoStream is null) + { + return string.Empty; + } - return args; + if (!state.IsOutputVideo) + { + return string.Empty; } - /// <summary> - /// Gets the video arguments for transcoding. - /// </summary> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <param name="startNumber">The first number in the hls sequence.</param> - /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param> - /// <returns>The command line arguments for video transcoding.</returns> - private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) + var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + + var args = "-codec:v:0 " + codec; + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { - if (state.VideoStream is null) + if (EncodingHelper.IsCopyCodec(codec) + && (state.VideoStream.VideoRangeType == VideoRangeType.DOVI + || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase))) { - return string.Empty; + // Prefer dvh1 to dvhe + args += " -tag:v:0 dvh1 -strict -2"; } - - if (!state.IsOutputVideo) + else { - return string.Empty; + // Prefer hvc1 to hev1 + args += " -tag:v:0 hvc1"; } + } - var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - - var args = "-codec:v:0 " + codec; + // if (state.EnableMpegtsM2TsMode) + // { + // args += " -mpegts_m2ts_mode 1"; + // } - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + // See if we can save come cpu cycles by avoiding encoding. + if (EncodingHelper.IsCopyCodec(codec)) + { + // If h264_mp4toannexb is ever added, do not use it for live tv. + if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - if (EncodingHelper.IsCopyCodec(codec) - && (string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase))) + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); + if (!string.IsNullOrEmpty(bitStreamArgs)) { - // Prefer dvh1 to dvhe - args += " -tag:v:0 dvh1 -strict -2"; - } - else - { - // Prefer hvc1 to hev1 - args += " -tag:v:0 hvc1"; + args += " " + bitStreamArgs; } } - // if (state.EnableMpegtsM2TsMode) - // { - // args += " -mpegts_m2ts_mode 1"; - // } + args += " -start_at_zero"; + } + else + { + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset); - // See if we can save come cpu cycles by avoiding encoding. - if (EncodingHelper.IsCopyCodec(codec)) - { - // If h264_mp4toannexb is ever added, do not use it for live tv. - if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) - { - string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); - if (!string.IsNullOrEmpty(bitStreamArgs)) - { - args += " " + bitStreamArgs; - } - } + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber); - args += " -start_at_zero"; - } - else + // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) { - args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset); + args += " -bf 0"; + } - // Set the key frame params for video encoding to match the hls segment time. - args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber); + // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; - // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. - if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) - { - args += " -bf 0"; - } + // video processing filters. + var videoProcessParam = _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec); - // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; + var negativeMapArgs = _encodingHelper.GetNegativeMapArgsByFilters(state, videoProcessParam); - // video processing filters. - args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec); + args = negativeMapArgs + args + videoProcessParam; - // -start_at_zero is necessary to use with -ss when seeking, - // otherwise the target position cannot be determined. - if (state.SubtitleStream is not null) + // -start_at_zero is necessary to use with -ss when seeking, + // otherwise the target position cannot be determined. + if (state.SubtitleStream is not null) + { + // Disable start_at_zero for external graphical subs + if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) { - // Disable start_at_zero for external graphical subs - if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) - { - args += " -start_at_zero"; - } + args += " -start_at_zero"; } } + } - // TODO why was this not enabled for VOD? - if (isEventPlaylist) - { - args += " -flags -global_header"; - } + // TODO why was this not enabled for VOD? + if (isEventPlaylist) + { + args += " -flags -global_header"; + } - if (!string.IsNullOrEmpty(state.OutputVideoSync)) - { - args += " -vsync " + state.OutputVideoSync; - } + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + args += " -vsync " + state.OutputVideoSync; + } - args += _encodingHelper.GetOutputFFlags(state); + args += _encodingHelper.GetOutputFFlags(state); - return args; - } + return args; + } - private string GetSegmentPath(StreamState state, string playlist, int index) - { - var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist)); - var filename = Path.GetFileNameWithoutExtension(playlist); + private string GetSegmentPath(StreamState state, string playlist, int index) + { + var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist)); + var filename = Path.GetFileNameWithoutExtension(playlist); - return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer)); - } + return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer)); + } - private async Task<ActionResult> GetSegmentResult( - StreamState state, - string playlistPath, - string segmentPath, - string segmentExtension, - int segmentIndex, - TranscodingJobDto? transcodingJob, - CancellationToken cancellationToken) + private async Task<ActionResult> GetSegmentResult( + StreamState state, + string playlistPath, + string segmentPath, + string segmentExtension, + int segmentIndex, + TranscodingJobDto? transcodingJob, + CancellationToken cancellationToken) + { + var segmentExists = System.IO.File.Exists(segmentPath); + if (segmentExists) { - var segmentExists = System.IO.File.Exists(segmentPath); - if (segmentExists) + if (transcodingJob is not null && transcodingJob.HasExited) { - if (transcodingJob is not null && transcodingJob.HasExited) - { - // Transcoding job is over, so assume all existing files are ready - _logger.LogDebug("serving up {0} as transcode is over", segmentPath); - return GetSegmentResult(state, segmentPath, transcodingJob); - } + // Transcoding job is over, so assume all existing files are ready + _logger.LogDebug("serving up {0} as transcode is over", segmentPath); + return GetSegmentResult(state, segmentPath, transcodingJob); + } - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready - if (segmentIndex < currentTranscodingIndex) - { - _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex); - return GetSegmentResult(state, segmentPath, transcodingJob); - } + // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready + if (segmentIndex < currentTranscodingIndex) + { + _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex); + return GetSegmentResult(state, segmentPath, transcodingJob); } + } - var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1); - if (transcodingJob is not null) + var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1); + if (transcodingJob is not null) + { + while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited) { - while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited) + // To be considered ready, the segment file has to exist AND + // either the transcoding job should be done or next segment should also exist + if (segmentExists) { - // To be considered ready, the segment file has to exist AND - // either the transcoding job should be done or next segment should also exist - if (segmentExists) + if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) { - if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) - { - _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); - return GetSegmentResult(state, segmentPath, transcodingJob); - } + _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); + return GetSegmentResult(state, segmentPath, transcodingJob); } - else - { - segmentExists = System.IO.File.Exists(segmentPath); - if (segmentExists) - { - continue; // avoid unnecessary waiting if segment just became available - } - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - - if (!System.IO.File.Exists(segmentPath)) - { - _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath); } else { - _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath); + segmentExists = System.IO.File.Exists(segmentPath); + if (segmentExists) + { + continue; // avoid unnecessary waiting if segment just became available + } } - cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + if (!System.IO.File.Exists(segmentPath)) + { + _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath); } else { - _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); + _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath); } - return GetSegmentResult(state, segmentPath, transcodingJob); + cancellationToken.ThrowIfCancellationRequested(); } - - private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob) + else { - var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; - - Response.OnCompleted(() => - { - _logger.LogDebug("Finished serving {SegmentPath}", segmentPath); - if (transcodingJob is not null) - { - transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); - } + _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); + } - return Task.CompletedTask; - }); + return GetSegmentResult(state, segmentPath, transcodingJob); + } - return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)); - } + private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob) + { + var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; - private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + Response.OnCompleted(() => { - var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); - - if (job is null || job.HasExited) + _logger.LogDebug("Finished serving {SegmentPath}", segmentPath); + if (transcodingJob is not null) { - return null; + transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); } - var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem); - - if (file is null) - { - return null; - } + return Task.CompletedTask; + }); - var playlistFilename = Path.GetFileNameWithoutExtension(playlist); + return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)); + } - var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); + private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + { + var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); - return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); + if (job is null || job.HasExited) + { + return null; } - private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) + var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem); + + if (file is null) { - var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist)); + return null; + } - var filePrefix = Path.GetFileNameWithoutExtension(playlist); + var playlistFilename = Path.GetFileNameWithoutExtension(playlist); - try - { - return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) - .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(fileSystem.GetLastWriteTimeUtc) - .FirstOrDefault(); - } - catch (IOException) - { - return null; - } - } + var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); - private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) + { + var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist)); + + var filePrefix = Path.GetFileNameWithoutExtension(playlist); + + try { - var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); + return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) + .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) + .MaxBy(fileSystem.GetLastWriteTimeUtc); + } + catch (IOException) + { + return null; + } + } - if (file is not null) - { - DeleteFile(file.FullName, retryCount); - } + private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + { + var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); + + if (file is not null) + { + DeleteFile(file.FullName, retryCount); } + } - private void DeleteFile(string path, int retryCount) + private void DeleteFile(string path, int retryCount) + { + if (retryCount >= 5) { - if (retryCount >= 5) - { - return; - } + return; + } - _logger.LogDebug("Deleting partial HLS file {Path}", path); + _logger.LogDebug("Deleting partial HLS file {Path}", path); - try - { - _fileSystem.DeleteFile(path); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + try + { + _fileSystem.DeleteFile(path); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - var task = Task.Delay(100); - task.Wait(); - DeleteFile(path, retryCount + 1); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - } + var task = Task.Delay(100); + task.Wait(); + DeleteFile(path, retryCount + 1); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); } } } diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs index 6c78a79875..8c9ee1a19e 100644 --- a/Jellyfin.Api/Controllers/EnvironmentController.cs +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -12,186 +12,185 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Environment Controller. +/// </summary> +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class EnvironmentController : BaseJellyfinApiController { + private const char UncSeparator = '\\'; + private const string UncStartPrefix = @"\\"; + + private readonly IFileSystem _fileSystem; + private readonly ILogger<EnvironmentController> _logger; + /// <summary> - /// Environment Controller. + /// Initializes a new instance of the <see cref="EnvironmentController"/> class. /// </summary> - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class EnvironmentController : BaseJellyfinApiController + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param> + public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger) { - private const char UncSeparator = '\\'; - private const string UncStartPrefix = @"\\"; - - private readonly IFileSystem _fileSystem; - private readonly ILogger<EnvironmentController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="EnvironmentController"/> class. - /// </summary> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param> - public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger) - { - _fileSystem = fileSystem; - _logger = logger; - } + _fileSystem = fileSystem; + _logger = logger; + } - /// <summary> - /// Gets the contents of a given directory in the file system. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param> - /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param> - /// <response code="200">Directory contents returned.</response> - /// <returns>Directory contents.</returns> - [HttpGet("DirectoryContents")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<FileSystemEntryInfo> GetDirectoryContents( - [FromQuery, Required] string path, - [FromQuery] bool includeFiles = false, - [FromQuery] bool includeDirectories = false) + /// <summary> + /// Gets the contents of a given directory in the file system. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param> + /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param> + /// <response code="200">Directory contents returned.</response> + /// <returns>Directory contents.</returns> + [HttpGet("DirectoryContents")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FileSystemEntryInfo> GetDirectoryContents( + [FromQuery, Required] string path, + [FromQuery] bool includeFiles = false, + [FromQuery] bool includeDirectories = false) + { + if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase) + && path.LastIndexOf(UncSeparator) == 1) { - if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase) - && path.LastIndexOf(UncSeparator) == 1) - { - return Array.Empty<FileSystemEntryInfo>(); - } + return Array.Empty<FileSystemEntryInfo>(); + } - var entries = - _fileSystem.GetFileSystemEntries(path) - .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles)) - .OrderBy(i => i.FullName); + var entries = + _fileSystem.GetFileSystemEntries(path) + .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles)) + .OrderBy(i => i.FullName); - return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File)); - } + return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File)); + } - /// <summary> - /// Validates path. - /// </summary> - /// <param name="validatePathDto">Validate request object.</param> - /// <response code="204">Path validated.</response> - /// <response code="404">Path not found.</response> - /// <returns>Validation status.</returns> - [HttpPost("ValidatePath")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) + /// <summary> + /// Validates path. + /// </summary> + /// <param name="validatePathDto">Validate request object.</param> + /// <response code="204">Path validated.</response> + /// <response code="404">Path not found.</response> + /// <returns>Validation status.</returns> + [HttpPost("ValidatePath")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) + { + if (validatePathDto.IsFile.HasValue) { - if (validatePathDto.IsFile.HasValue) + if (validatePathDto.IsFile.Value) { - if (validatePathDto.IsFile.Value) + if (!System.IO.File.Exists(validatePathDto.Path)) { - if (!System.IO.File.Exists(validatePathDto.Path)) - { - return NotFound(); - } - } - else - { - if (!Directory.Exists(validatePathDto.Path)) - { - return NotFound(); - } + return NotFound(); } } else { - if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path)) + if (!Directory.Exists(validatePathDto.Path)) { return NotFound(); } + } + } + else + { + if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path)) + { + return NotFound(); + } - if (validatePathDto.ValidateWritable) + if (validatePathDto.ValidateWritable) + { + if (validatePathDto.Path is null) { - if (validatePathDto.Path is null) - { - throw new ResourceNotFoundException(nameof(validatePathDto.Path)); - } + throw new ResourceNotFoundException(nameof(validatePathDto.Path)); + } - var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); - try - { - System.IO.File.WriteAllText(file, string.Empty); - } - finally + var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); + try + { + System.IO.File.WriteAllText(file, string.Empty); + } + finally + { + if (System.IO.File.Exists(file)) { - if (System.IO.File.Exists(file)) - { - System.IO.File.Delete(file); - } + System.IO.File.Delete(file); } } } - - return NoContent(); } - /// <summary> - /// Gets network paths. - /// </summary> - /// <response code="200">Empty array returned.</response> - /// <returns>List of entries.</returns> - [Obsolete("This endpoint is obsolete.")] - [HttpGet("NetworkShares")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares() - { - _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); - return Array.Empty<FileSystemEntryInfo>(); - } + return NoContent(); + } - /// <summary> - /// Gets available drives from the server's file system. - /// </summary> - /// <response code="200">List of entries returned.</response> - /// <returns>List of entries.</returns> - [HttpGet("Drives")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<FileSystemEntryInfo> GetDrives() - { - return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); - } + /// <summary> + /// Gets network paths. + /// </summary> + /// <response code="200">Empty array returned.</response> + /// <returns>List of entries.</returns> + [Obsolete("This endpoint is obsolete.")] + [HttpGet("NetworkShares")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares() + { + _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); + return Array.Empty<FileSystemEntryInfo>(); + } - /// <summary> - /// Gets the parent path of a given path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns>Parent path.</returns> - [HttpGet("ParentPath")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<string?> GetParentPath([FromQuery, Required] string path) + /// <summary> + /// Gets available drives from the server's file system. + /// </summary> + /// <response code="200">List of entries returned.</response> + /// <returns>List of entries.</returns> + [HttpGet("Drives")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FileSystemEntryInfo> GetDrives() + { + return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); + } + + /// <summary> + /// Gets the parent path of a given path. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>Parent path.</returns> + [HttpGet("ParentPath")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<string?> GetParentPath([FromQuery, Required] string path) + { + string? parent = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(parent)) { - string? parent = Path.GetDirectoryName(path); - if (string.IsNullOrEmpty(parent)) + // Check if unc share + var index = path.LastIndexOf(UncSeparator); + + if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0) { - // Check if unc share - var index = path.LastIndexOf(UncSeparator); + parent = path.Substring(0, index); - if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0) + if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) { - parent = path.Substring(0, index); - - if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) - { - parent = null; - } + parent = null; } } - - return parent; } - /// <summary> - /// Get Default directory browser. - /// </summary> - /// <response code="200">Default directory browser returned.</response> - /// <returns>Default directory browser.</returns> - [HttpGet("DefaultDirectoryBrowser")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser() - { - return new DefaultDirectoryBrowserInfoDto(); - } + return parent; + } + + /// <summary> + /// Get Default directory browser. + /// </summary> + /// <response code="200">Default directory browser returned.</response> + /// <returns>Default directory browser.</returns> + [HttpGet("DefaultDirectoryBrowser")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser() + { + return new DefaultDirectoryBrowserInfoDto(); } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 17d136384e..d51a5325f5 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,6 +1,6 @@ using System; using System.Linq; -using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; @@ -12,205 +12,206 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Filters controller. +/// </summary> +[Route("")] +[Authorize] +public class FilterController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + /// <summary> - /// Filters controller. + /// Initializes a new instance of the <see cref="FilterController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class FilterController : BaseJellyfinApiController + /// <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) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - - /// <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) + _libraryManager = libraryManager; + _userManager = userManager; + } + + /// <summary> + /// Gets legacy query filters. + /// </summary> + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Parent id.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <response code="200">Legacy filters retrieved.</response> + /// <returns>Legacy query filters.</returns> + [HttpGet("Items/Filters")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( + [FromQuery] Guid? userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + BaseItem? item = null; + if (includeItemTypes.Length != 1 + || !(includeItemTypes[0] == BaseItemKind.BoxSet + || includeItemTypes[0] == BaseItemKind.Playlist + || includeItemTypes[0] == BaseItemKind.Trailer + || includeItemTypes[0] == BaseItemKind.Program)) { - _libraryManager = libraryManager; - _userManager = userManager; + item = _libraryManager.GetParentItem(parentId, user?.Id); } - /// <summary> - /// Gets legacy query filters. - /// </summary> - /// <param name="userId">Optional. User id.</param> - /// <param name="parentId">Optional. Parent id.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> - /// <response code="200">Legacy filters retrieved.</response> - /// <returns>Legacy query filters.</returns> - [HttpGet("Items/Filters")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( - [FromQuery] Guid? userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes) + var query = new InternalItemsQuery { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - BaseItem? item = null; - if (includeItemTypes.Length != 1 - || !(includeItemTypes[0] == BaseItemKind.BoxSet - || includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.Trailer - || includeItemTypes[0] == BaseItemKind.Program)) - { - item = _libraryManager.GetParentItem(parentId, user?.Id); - } - - var query = new InternalItemsQuery - { - User = user, - MediaTypes = mediaTypes, - IncludeItemTypes = includeItemTypes, - Recursive = true, - EnableTotalRecordCount = false, - DtoOptions = new DtoOptions - { - Fields = new[] { ItemFields.Genres, ItemFields.Tags }, - EnableImages = false, - EnableUserData = false - } - }; - - if (item is not Folder folder) + User = user, + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + Recursive = true, + EnableTotalRecordCount = false, + DtoOptions = new DtoOptions { - return new QueryFiltersLegacy(); + Fields = new[] { ItemFields.Genres, ItemFields.Tags }, + EnableImages = false, + EnableUserData = false } + }; - var itemList = folder.GetItemList(query); - return new QueryFiltersLegacy - { - Years = itemList.Select(i => i.ProductionYear ?? -1) - .Where(i => i > 0) - .Distinct() - .Order() - .ToArray(), - - Genres = itemList.SelectMany(i => i.Genres) - .DistinctNames() - .Order() - .ToArray(), - - Tags = itemList - .SelectMany(i => i.Tags) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray(), - - OfficialRatings = itemList - .Select(i => i.OfficialRating) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray() - }; + if (item is not Folder folder) + { + return new QueryFiltersLegacy(); } - /// <summary> - /// Gets query filters. - /// </summary> - /// <param name="userId">Optional. User id.</param> - /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="isAiring">Optional. Is item airing.</param> - /// <param name="isMovie">Optional. Is item movie.</param> - /// <param name="isSports">Optional. Is item sports.</param> - /// <param name="isKids">Optional. Is item kids.</param> - /// <param name="isNews">Optional. Is item news.</param> - /// <param name="isSeries">Optional. Is item series.</param> - /// <param name="recursive">Optional. Search recursive.</param> - /// <response code="200">Filters retrieved.</response> - /// <returns>Query filters.</returns> - [HttpGet("Items/Filters2")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryFilters> GetQueryFilters( - [FromQuery] Guid? userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isAiring, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSports, - [FromQuery] bool? isKids, - [FromQuery] bool? isNews, - [FromQuery] bool? isSeries, - [FromQuery] bool? recursive) + var itemList = folder.GetItemList(query); + return new QueryFiltersLegacy { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - BaseItem? parentItem = null; - if (includeItemTypes.Length == 1 - && (includeItemTypes[0] == BaseItemKind.BoxSet - || includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.Trailer - || includeItemTypes[0] == BaseItemKind.Program)) - { - parentItem = null; - } - else if (parentId.HasValue) - { - parentItem = _libraryManager.GetItemById(parentId.Value); - } + Years = itemList.Select(i => i.ProductionYear ?? -1) + .Where(i => i > 0) + .Distinct() + .Order() + .ToArray(), + + Genres = itemList.SelectMany(i => i.Genres) + .DistinctNames() + .Order() + .ToArray(), + + Tags = itemList + .SelectMany(i => i.Tags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToArray(), + + OfficialRatings = itemList + .Select(i => i.OfficialRating) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToArray() + }; + } - var filters = new QueryFilters(); - var genreQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = includeItemTypes, - DtoOptions = new DtoOptions - { - Fields = Array.Empty<ItemFields>(), - EnableImages = false, - EnableUserData = false - }, - IsAiring = isAiring, - IsMovie = isMovie, - IsSports = isSports, - IsKids = isKids, - IsNews = isNews, - IsSeries = isSeries - }; - - if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) - { - genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; - } - else + /// <summary> + /// Gets query filters. + /// </summary> + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="isAiring">Optional. Is item airing.</param> + /// <param name="isMovie">Optional. Is item movie.</param> + /// <param name="isSports">Optional. Is item sports.</param> + /// <param name="isKids">Optional. Is item kids.</param> + /// <param name="isNews">Optional. Is item news.</param> + /// <param name="isSeries">Optional. Is item series.</param> + /// <param name="recursive">Optional. Search recursive.</param> + /// <response code="200">Filters retrieved.</response> + /// <returns>Query filters.</returns> + [HttpGet("Items/Filters2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFilters> GetQueryFilters( + [FromQuery] Guid? userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isAiring, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSports, + [FromQuery] bool? isKids, + [FromQuery] bool? isNews, + [FromQuery] bool? isSeries, + [FromQuery] bool? recursive) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + BaseItem? parentItem = null; + if (includeItemTypes.Length == 1 + && (includeItemTypes[0] == BaseItemKind.BoxSet + || includeItemTypes[0] == BaseItemKind.Playlist + || includeItemTypes[0] == BaseItemKind.Trailer + || includeItemTypes[0] == BaseItemKind.Program)) + { + parentItem = null; + } + else if (parentId.HasValue) + { + parentItem = _libraryManager.GetItemById(parentId.Value); + } + + var filters = new QueryFilters(); + var genreQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions { - genreQuery.Parent = parentItem; - } + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) + { + genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + } + else + { + genreQuery.Parent = parentItem; + } - if (includeItemTypes.Length == 1 - && (includeItemTypes[0] == BaseItemKind.MusicAlbum - || includeItemTypes[0] == BaseItemKind.MusicVideo - || includeItemTypes[0] == BaseItemKind.MusicArtist - || includeItemTypes[0] == BaseItemKind.Audio)) + if (includeItemTypes.Length == 1 + && (includeItemTypes[0] == BaseItemKind.MusicAlbum + || includeItemTypes[0] == BaseItemKind.MusicVideo + || includeItemTypes[0] == BaseItemKind.MusicArtist + || includeItemTypes[0] == BaseItemKind.Audio)) + { + filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair { - filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair - { - Name = i.Item.Name, - Id = i.Item.Id - }).ToArray(); - } - else + Name = i.Item.Name, + Id = i.Item.Id + }).ToArray(); + } + else + { + filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair { - filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair - { - Name = i.Item.Name, - Id = i.Item.Id - }).ToArray(); - } - - return filters; + Name = i.Item.Name, + Id = i.Item.Id + }).ToArray(); } + + return filters; } } diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 611643bd8a..da60f2c60b 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -18,194 +17,192 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Genre = MediaBrowser.Controller.Entities.Genre; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The genres controller. +/// </summary> +[Authorize] +public class GenresController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + /// <summary> - /// The genres controller. + /// Initializes a new instance of the <see cref="GenresController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class GenresController : BaseJellyfinApiController + /// <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> + public GenresController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="GenresController"/> 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> - public GenresController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - } + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + } - /// <summary> - /// Gets all genres from a given item, folder, or the entire library. - /// </summary> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> - /// <response code="200">Genres returned.</response> - /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetGenres( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isFavorite, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + /// <summary> + /// Gets all genres from a given item, folder, or the entire library. + /// </summary> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> + /// <response code="200">Genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetGenres( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isFavorite, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + User? user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - var parentItem = _libraryManager.GetParentItem(parentId, userId); + var parentItem = _libraryManager.GetParentItem(parentId, userId); - var query = new InternalItemsQuery(user) - { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } - } - - QueryResult<(BaseItem, ItemCounts)> result; - if (parentItem is ICollectionFolder parentCollectionFolder - && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal) - || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal))) + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; + + if (parentId.HasValue) + { + if (parentItem is Folder) { - result = _libraryManager.GetMusicGenres(query); + query.AncestorIds = new[] { parentId.Value }; } else { - result = _libraryManager.GetGenres(query); + query.ItemIds = new[] { parentId.Value }; } - - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } - /// <summary> - /// Gets a genre, by name. - /// </summary> - /// <param name="genreName">The genre name.</param> - /// <param name="userId">The user id.</param> - /// <response code="200">Genres returned.</response> - /// <returns>An <see cref="OkResult"/> containing the genre.</returns> - [HttpGet("{genreName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + QueryResult<(BaseItem, ItemCounts)> result; + if (parentItem is ICollectionFolder parentCollectionFolder + && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal) + || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal))) { - var dtoOptions = new DtoOptions() - .AddClientFields(User); + result = _libraryManager.GetMusicGenres(query); + } + else + { + result = _libraryManager.GetGenres(query); + } - Genre? item; - if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase)) - { - item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre); - } - else - { - item = _libraryManager.GetGenre(genreName); - } + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } - item ??= new Genre(); + /// <summary> + /// Gets a genre, by name. + /// </summary> + /// <param name="genreName">The genre name.</param> + /// <param name="userId">The user id.</param> + /// <response code="200">Genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the genre.</returns> + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions() + .AddClientFields(User); - if (userId is null || userId.Value.Equals(default)) - { - return _dtoService.GetBaseItemDto(item, dtoOptions); - } + Genre? item; + if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase)) + { + item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre); + } + else + { + item = _libraryManager.GetGenre(genreName); + } - var user = _userManager.GetUserById(userId.Value); + item ??= new Genre(); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) - where T : BaseItem, new() + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery { - var result = libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 50fee233a8..d7cec865e1 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; @@ -15,178 +14,177 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The hls segment controller. +/// </summary> +[Route("")] +public class HlsSegmentController : BaseJellyfinApiController { + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + + /// <summary> + /// Initializes a new instance of the <see cref="HlsSegmentController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param> + public HlsSegmentController( + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager, + TranscodingJobHelper transcodingJobHelper) + { + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + _transcodingJobHelper = transcodingJobHelper; + } + /// <summary> - /// The hls segment controller. + /// Gets the specified audio segment for an audio item. /// </summary> - [Route("")] - public class HlsSegmentController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="segmentId">The segment id.</param> + /// <response code="200">Hls audio segment returned.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + // [Authenticated] + [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")] + [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] + public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) { - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="HlsSegmentController"/> class. - /// </summary> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param> - public HlsSegmentController( - IFileSystem fileSystem, - IServerConfigurationManager serverConfigurationManager, - TranscodingJobHelper transcodingJobHelper) + // TODO: Deprecate with new iOS app + var file = segmentId + Path.GetExtension(Request.Path); + 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)) { - _fileSystem = fileSystem; - _serverConfigurationManager = serverConfigurationManager; - _transcodingJobHelper = transcodingJobHelper; + return BadRequest("Invalid segment."); } - /// <summary> - /// Gets the specified audio segment for an audio item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="segmentId">The segment id.</param> - /// <response code="200">Hls audio segment returned.</response> - /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns> - // Can't require authentication just yet due to seeing some requests come from Chrome without full query string - // [Authenticated] - [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")] - [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] - public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) - { - // TODO: Deprecate with new iOS app - var file = segmentId + Path.GetExtension(Request.Path); - 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)) - { - return BadRequest("Invalid segment."); - } + return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)); + } - return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)); + /// <summary> + /// Gets a hls video playlist. + /// </summary> + /// <param name="itemId">The video id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <response code="200">Hls video playlist returned.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns> + [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] + public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) + { + var file = playlistId + Path.GetExtension(Request.Path); + 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) != ".m3u8") + { + return BadRequest("Invalid segment."); } - /// <summary> - /// Gets a hls video playlist. - /// </summary> - /// <param name="itemId">The video id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <response code="200">Hls video playlist returned.</response> - /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns> - [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] - public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) - { - var file = playlistId + Path.GetExtension(Request.Path); - 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) != ".m3u8") - { - return BadRequest("Invalid segment."); - } + return GetFileResult(file, file); + } - return GetFileResult(file, file); - } + /// <summary> + /// Stops an active encoding. + /// </summary> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="playSessionId">The play session id.</param> + /// <response code="204">Encoding stopped successfully.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpDelete("Videos/ActiveEncodings")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult StopEncodingProcess( + [FromQuery, Required] string deviceId, + [FromQuery, Required] string playSessionId) + { + _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); + return NoContent(); + } + + /// <summary> + /// Gets a hls video segment. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <response code="200">Hls video segment returned.</response> + /// <response code="404">Hls segment not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + // [Authenticated] + [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] + public ActionResult GetHlsVideoSegmentLegacy( + [FromRoute, Required] string itemId, + [FromRoute, Required] string playlistId, + [FromRoute, Required] string segmentId, + [FromRoute, Required] string segmentContainer) + { + var file = segmentId + Path.GetExtension(Request.Path); + var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); - /// <summary> - /// Stops an active encoding. - /// </summary> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="playSessionId">The play session id.</param> - /// <response code="204">Encoding stopped successfully.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpDelete("Videos/ActiveEncodings")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult StopEncodingProcess( - [FromQuery, Required] string deviceId, - [FromQuery, Required] string playSessionId) + file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) { - _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); - return NoContent(); + return BadRequest("Invalid segment."); } - /// <summary> - /// Gets a hls video segment. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <param name="segmentId">The segment id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <response code="200">Hls video segment returned.</response> - /// <response code="404">Hls segment not found.</response> - /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> - // Can't require authentication just yet due to seeing some requests come from Chrome without full query string - // [Authenticated] - [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesVideoFile] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] - public ActionResult GetHlsVideoSegmentLegacy( - [FromRoute, Required] string itemId, - [FromRoute, Required] string playlistId, - [FromRoute, Required] string segmentId, - [FromRoute, Required] string segmentContainer) - { - var file = segmentId + Path.GetExtension(Request.Path); - var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); + var normalizedPlaylistId = playlistId; - file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) + var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); + // Add . to start of segment container for future use. + segmentContainer = segmentContainer.Insert(0, "."); + string? playlistPath = null; + foreach (var path in filePaths) + { + var pathExtension = Path.GetExtension(path); + if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) + || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) + && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1) { - return BadRequest("Invalid segment."); + playlistPath = path; + break; } + } - var normalizedPlaylistId = playlistId; - - var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); - // Add . to start of segment container for future use. - segmentContainer = segmentContainer.Insert(0, "."); - string? playlistPath = null; - foreach (var path in filePaths) - { - var pathExtension = Path.GetExtension(path); - if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) - || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) - && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1) - { - playlistPath = path; - break; - } - } + return playlistPath is null + ? NotFound("Hls segment not found.") + : GetFileResult(file, playlistPath); + } - return playlistPath is null - ? NotFound("Hls segment not found.") - : GetFileResult(file, playlistPath); - } + private ActionResult GetFileResult(string path, string playlistPath) + { + var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); - private ActionResult GetFileResult(string path, string playlistPath) + Response.OnCompleted(() => { - var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); - - Response.OnCompleted(() => + if (transcodingJob is not null) { - if (transcodingJob is not null) - { - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); - } + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); + } - return Task.CompletedTask; - }); + return Task.CompletedTask; + }); - return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)); - } + return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)); } } diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 996dc08196..3c5f18af55 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -30,2071 +30,2115 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Image controller. +/// </summary> +[Route("")] +public class ImageController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IImageProcessor _imageProcessor; + private readonly IFileSystem _fileSystem; + private readonly ILogger<ImageController> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IApplicationPaths _appPaths; + /// <summary> - /// Image controller. + /// Initializes a new instance of the <see cref="ImageController"/> class. /// </summary> - [Route("")] - public class ImageController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + public ImageController( + IUserManager userManager, + ILibraryManager libraryManager, + IProviderManager providerManager, + IImageProcessor imageProcessor, + IFileSystem fileSystem, + ILogger<ImageController> logger, + IServerConfigurationManager serverConfigurationManager, + IApplicationPaths appPaths) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IImageProcessor _imageProcessor; - private readonly IFileSystem _fileSystem; - private readonly ILogger<ImageController> _logger; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IApplicationPaths _appPaths; - - /// <summary> - /// Initializes a new instance of the <see cref="ImageController"/> 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="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> - public ImageController( - IUserManager userManager, - ILibraryManager libraryManager, - IProviderManager providerManager, - IImageProcessor imageProcessor, - IFileSystem fileSystem, - ILogger<ImageController> logger, - IServerConfigurationManager serverConfigurationManager, - IApplicationPaths appPaths) - { - _userManager = userManager; - _libraryManager = libraryManager; - _providerManager = providerManager; - _imageProcessor = imageProcessor; - _fileSystem = fileSystem; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; - _appPaths = appPaths; - } - - /// <summary> - /// Sets the user image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image updated.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/Images/{imageType}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> PostUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? index = null) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); - } - - var user = _userManager.GetUserById(userId); - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); - if (user.ProfileImage is not null) - { - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); - } + _userManager = userManager; + _libraryManager = libraryManager; + _providerManager = providerManager; + _imageProcessor = imageProcessor; + _fileSystem = fileSystem; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + _appPaths = appPaths; + } - user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty))); + /// <summary> + /// Sets the user image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image updated.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/Images/{imageType}")] + [Authorize] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> PostUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? index = null) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } - await _providerManager - .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) - .ConfigureAwait(false); - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + } - return NoContent(); - } + if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension)) + { + return BadRequest("Incorrect ContentType."); } - /// <summary> - /// Sets the user image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image updated.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/Images/{imageType}/{index}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> PostUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int index) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + if (user.ProfileImage is not null) { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - var user = _userManager.GetUserById(userId); - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); - if (user.ProfileImage is not null) - { - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); - } + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension)); - user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty))); + await _providerManager + .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .ConfigureAwait(false); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - await _providerManager - .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) - .ConfigureAwait(false); - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + return NoContent(); + } + } - return NoContent(); - } + /// <summary> + /// Sets the user image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image updated.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/Images/{imageType}/{index}")] + [Authorize] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> PostUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int index) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); } - /// <summary> - /// Delete the user's image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Users/{userId}/Images/{imageType}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> DeleteUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? index = null) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); - } + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + } - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NoContent(); - } + if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension)) + { + return BadRequest("Incorrect ContentType."); + } - try - { - System.IO.File.Delete(user.ProfileImage.Path); - } - catch (IOException e) + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + if (user.ProfileImage is not null) { - _logger.LogError(e, "Error deleting user profile image:"); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension)); + + await _providerManager + .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .ConfigureAwait(false); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + return NoContent(); } + } - /// <summary> - /// Delete the user's image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Users/{userId}/Images/{imageType}/{index}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> DeleteUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int index) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); - } + /// <summary> + /// Delete the user's image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/Images/{imageType}")] + [Authorize] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> DeleteUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? index = null) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); + } - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NoContent(); - } + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { + return NoContent(); + } - try - { - System.IO.File.Delete(user.ProfileImage.Path); - } - catch (IOException e) - { - _logger.LogError(e, "Error deleting user profile image:"); - } + try + { + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } + + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Delete the user's image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/Images/{imageType}/{index}")] + [Authorize] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> DeleteUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int index) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); + } - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { return NoContent(); } - /// <summary> - /// Delete an item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">The image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpDelete("Items/{itemId}/Images/{imageType}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + try + { + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } - await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false); - return NoContent(); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Delete an item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">The image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Items/{itemId}/Images/{imageType}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Delete an item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">The image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false); + return NoContent(); + } - await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false); - return NoContent(); + /// <summary> + /// Delete an item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">The image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Set item image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <response code="204">Image saved.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpPost("Items/{itemId}/Images/{imageType}")] - [Authorize(Policy = Policies.RequiresElevation)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> SetItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false); + return NoContent(); + } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + /// <summary> + /// Set item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <response code="204">Image saved.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}")] + [Authorize(Policy = Policies.RequiresElevation)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> SetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } - return NoContent(); - } + if (!TryGetImageExtensionFromContentType(Request.ContentType, out _)) + { + return BadRequest("Incorrect ContentType."); } - /// <summary> - /// Set item image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">(Unused) Image index.</param> - /// <response code="204">Image saved.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [Authorize(Policy = Policies.RequiresElevation)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> SetItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + } - return NoContent(); - } + /// <summary> + /// Set item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">(Unused) Image index.</param> + /// <response code="204">Image saved.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [Authorize(Policy = Policies.RequiresElevation)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> SetItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Updates the index for an item image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Old image index.</param> - /// <param name="newIndex">New image index.</param> - /// <response code="204">Image index updated.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateItemImageIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery, Required] int newIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + if (!TryGetImageExtensionFromContentType(Request.ContentType, out _)) + { + return BadRequest("Incorrect ContentType."); + } + + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false); return NoContent(); } + } - /// <summary> - /// Get item image infos. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item images returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns> - [HttpGet("Items/{itemId}/Images")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + /// <summary> + /// Updates the index for an item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Old image index.</param> + /// <param name="newIndex">New image index.</param> + /// <response code="204">Image index updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateItemImageIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery, Required] int newIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } - var list = new List<ImageInfo>(); - var itemImages = item.ImageInfos; + await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false); + return NoContent(); + } - if (itemImages.Length == 0) - { - // short-circuit - return list; - } + /// <summary> + /// Get item image infos. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item images returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns> + [HttpGet("Items/{itemId}/Images")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } - await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct + var list = new List<ImageInfo>(); + var itemImages = item.ImageInfos; - foreach (var image in itemImages) + if (itemImages.Length == 0) + { + // short-circuit + return list; + } + + await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct + + foreach (var image in itemImages) + { + if (!item.AllowsMultipleImages(image.Type)) { - if (!item.AllowsMultipleImages(image.Type)) - { - var info = GetImageInfo(item, image, null); + var info = GetImageInfo(item, image, null); - if (info is not null) - { - list.Add(info); - } + if (info is not null) + { + list.Add(info); } } + } - foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages)) - { - var index = 0; - - // Prevent implicitly captured closure - var currentImageType = imageType; + foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages)) + { + var index = 0; - foreach (var image in itemImages.Where(i => i.Type == currentImageType)) - { - var info = GetImageInfo(item, image, index); + // Prevent implicitly captured closure + var currentImageType = imageType; - if (info is not null) - { - list.Add(info); - } + foreach (var image in itemImages.Where(i => i.Type == currentImageType)) + { + var info = GetImageInfo(item, image, index); - index++; + if (info is not null) + { + list.Add(info); } - } - return list; + index++; + } } - /// <summary> - /// Gets the item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}")] - [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery] string? tag, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] ImageFormat? format, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return list; + } - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}")] + [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets the item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery] string? tag, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] ImageFormat? format, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets the item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")] - [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetItemImage2( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int maxWidth, - [FromRoute, Required] int maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromRoute, Required] string tag, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromRoute, Required] ImageFormat format, - [FromRoute, Required] double percentPlayed, - [FromRoute, Required] int unplayedCount, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute, Required] int imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImage2( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int maxWidth, + [FromRoute, Required] int maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromRoute, Required] string tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromRoute, Required] ImageFormat format, + [FromRoute, Required] double percentPlayed, + [FromRoute, Required] int unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get artist image by name. - /// </summary> - /// <param name="name">Artist name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetArtistImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute, Required] int imageIndex) - { - var item = _libraryManager.GetArtist(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get artist image by name. + /// </summary> + /// <param name="name">Artist name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetArtistImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetArtist(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get genre image by name. - /// </summary> - /// <param name="name">Genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Genres/{name}/Images/{imageType}")] - [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetGenreImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get genre image by name. + /// </summary> + /// <param name="name">Genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Genres/{name}/Images/{imageType}")] + [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetGenreImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get genre image by name. - /// </summary> - /// <param name="name">Genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetGenreImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get genre image by name. + /// </summary> + /// <param name="name">Genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetGenreImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get music genre image by name. - /// </summary> - /// <param name="name">Music genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("MusicGenres/{name}/Images/{imageType}")] - [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetMusicGenreImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetMusicGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get music genre image by name. + /// </summary> + /// <param name="name">Music genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("MusicGenres/{name}/Images/{imageType}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetMusicGenreImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetMusicGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get music genre image by name. - /// </summary> - /// <param name="name">Music genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetMusicGenreImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetMusicGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get music genre image by name. + /// </summary> + /// <param name="name">Music genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetMusicGenreImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetMusicGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get person image by name. - /// </summary> - /// <param name="name">Person name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Persons/{name}/Images/{imageType}")] - [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetPersonImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get person image by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Persons/{name}/Images/{imageType}")] + [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetPersonImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get person image by name. - /// </summary> - /// <param name="name">Person name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetPersonImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get person image by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetPersonImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get studio image by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Studios/{name}/Images/{imageType}")] - [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetStudioImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetStudio(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get studio image by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Studios/{name}/Images/{imageType}")] + [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetStudioImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetStudio(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get studio image by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetStudioImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetStudio(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get studio image by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetStudioImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetStudio(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get user profile image. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Users/{userId}/Images/{imageType}")] - [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NotFound(); - } - - var info = new ItemImageInfo - { - Path = user.ProfileImage.Path, - Type = ImageType.Profile, - DateModified = user.ProfileImage.LastModified - }; + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - if (width.HasValue) - { - info.Width = width.Value; - } + /// <summary> + /// Get user profile image. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Users/{userId}/Images/{imageType}")] + [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { + return NotFound(); + } - if (height.HasValue) - { - info.Height = height.Value; - } + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; - return await GetImageInternal( - user.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - null, - info) - .ConfigureAwait(false); + if (width.HasValue) + { + info.Width = width.Value; } - /// <summary> - /// Get user profile image. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")] - [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NotFound(); - } + if (height.HasValue) + { + info.Height = height.Value; + } - var info = new ItemImageInfo - { - Path = user.ProfileImage.Path, - Type = ImageType.Profile, - DateModified = user.ProfileImage.LastModified - }; + return await GetImageInternal( + user.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + null, + info) + .ConfigureAwait(false); + } - if (width.HasValue) - { - info.Width = width.Value; - } + /// <summary> + /// Get user profile image. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { + return NotFound(); + } - if (height.HasValue) - { - info.Height = height.Value; - } + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; - return await GetImageInternal( - user.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - null, - info) - .ConfigureAwait(false); + if (width.HasValue) + { + info.Width = width.Value; } - /// <summary> - /// Generates or gets the splashscreen. - /// </summary> - /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="blur">Blur image.</param> - /// <param name="backgroundColor">Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param> - /// <param name="quality">Quality setting, from 0-100.</param> - /// <response code="200">Splashscreen returned successfully.</response> - /// <returns>The splashscreen.</returns> - [HttpGet("Branding/Splashscreen")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesImageFile] - public async Task<ActionResult> GetSplashscreen( - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery, Range(0, 100)] int quality = 90) + if (height.HasValue) { - var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - if (!brandingOptions.SplashscreenEnabled) - { - return NotFound(); - } + info.Height = height.Value; + } - string splashscreenPath; + return await GetImageInternal( + user.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + null, + info) + .ConfigureAwait(false); + } - if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation) - && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) - { - splashscreenPath = brandingOptions.SplashscreenLocation; - } - else - { - splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png"); - if (!System.IO.File.Exists(splashscreenPath)) - { - return NotFound(); - } - } + /// <summary> + /// Generates or gets the splashscreen. + /// </summary> + /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="blur">Blur image.</param> + /// <param name="backgroundColor">Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param> + /// <param name="quality">Quality setting, from 0-100.</param> + /// <response code="200">Splashscreen returned successfully.</response> + /// <returns>The splashscreen.</returns> + [HttpGet("Branding/Splashscreen")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesImageFile] + public async Task<ActionResult> GetSplashscreen( + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery, Range(0, 100)] int quality = 90) + { + var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + if (!brandingOptions.SplashscreenEnabled) + { + return NotFound(); + } - var outputFormats = GetOutputFormats(format); + string splashscreenPath; - TimeSpan? cacheDuration = null; - if (!string.IsNullOrEmpty(tag)) + if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation) + && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) + { + splashscreenPath = brandingOptions.SplashscreenLocation; + } + else + { + splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png"); + if (!System.IO.File.Exists(splashscreenPath)) { - cacheDuration = TimeSpan.FromDays(365); + return NotFound(); } - - var options = new ImageProcessingOptions - { - Image = new ItemImageInfo - { - Path = splashscreenPath - }, - Height = height, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - FillHeight = fillHeight, - FillWidth = fillWidth, - Quality = quality, - Width = width, - Blur = blur, - BackgroundColor = backgroundColor, - ForegroundLayer = foregroundLayer, - SupportedOutputFormats = outputFormats - }; - - return await GetImageResult( - options, - cacheDuration, - ImmutableDictionary<string, string>.Empty) - .ConfigureAwait(false); } - /// <summary> - /// Uploads a custom splashscreen. - /// The body is expected to the image contents base64 encoded. - /// </summary> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - /// <response code="204">Successfully uploaded new splashscreen.</response> - /// <response code="400">Error reading MimeType from uploaded image.</response> - /// <response code="403">User does not have permission to upload splashscreen..</response> - /// <exception cref="ArgumentException">Error reading the image format.</exception> - [HttpPost("Branding/Splashscreen")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [AcceptsImageFile] - public async Task<ActionResult> UploadCustomSplashscreen() - { - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType; - - if (!mimeType.HasValue) - { - return BadRequest("Error reading mimetype from uploaded image"); - } + var outputFormats = GetOutputFormats(format); - var extension = MimeTypes.ToExtension(mimeType.Value); - if (string.IsNullOrEmpty(extension)) - { - return BadRequest("Error converting mimetype to an image extension"); - } - - var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension); - var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - brandingOptions.SplashscreenLocation = filePath; - _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); + TimeSpan? cacheDuration = null; + if (!string.IsNullOrEmpty(tag)) + { + cacheDuration = TimeSpan.FromDays(365); + } - var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await using (fs.ConfigureAwait(false)) - { - await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false); - } + var options = new ImageProcessingOptions + { + Image = new ItemImageInfo + { + Path = splashscreenPath + }, + Height = height, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + FillHeight = fillHeight, + FillWidth = fillWidth, + Quality = quality, + Width = width, + Blur = blur, + BackgroundColor = backgroundColor, + ForegroundLayer = foregroundLayer, + SupportedOutputFormats = outputFormats + }; + + return await GetImageResult( + options, + cacheDuration, + ImmutableDictionary<string, string>.Empty) + .ConfigureAwait(false); + } - return NoContent(); - } + /// <summary> + /// Uploads a custom splashscreen. + /// The body is expected to the image contents base64 encoded. + /// </summary> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + /// <response code="204">Successfully uploaded new splashscreen.</response> + /// <response code="400">Error reading MimeType from uploaded image.</response> + /// <response code="403">User does not have permission to upload splashscreen..</response> + /// <exception cref="ArgumentException">Error reading the image format.</exception> + [HttpPost("Branding/Splashscreen")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [AcceptsImageFile] + public async Task<ActionResult> UploadCustomSplashscreen() + { + if (!TryGetImageExtensionFromContentType(Request.ContentType, out var extension)) + { + return BadRequest("Incorrect ContentType."); } - /// <summary> - /// Delete a custom splashscreen. - /// </summary> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - /// <response code="204">Successfully deleted the custom splashscreen.</response> - /// <response code="403">User does not have permission to delete splashscreen..</response> - [HttpDelete("Branding/Splashscreen")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteCustomSplashscreen() + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) { + var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension); var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation) - && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) + brandingOptions.SplashscreenLocation = filePath; + _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); + + var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await using (fs.ConfigureAwait(false)) { - System.IO.File.Delete(brandingOptions.SplashscreenLocation); - brandingOptions.SplashscreenLocation = null; - _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); + await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false); } return NoContent(); } + } - private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) + /// <summary> + /// Delete a custom splashscreen. + /// </summary> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + /// <response code="204">Successfully deleted the custom splashscreen.</response> + /// <response code="403">User does not have permission to delete splashscreen..</response> + [HttpDelete("Branding/Splashscreen")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteCustomSplashscreen() + { + var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation) + && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) { - using var reader = new StreamReader(inputStream); - var text = await reader.ReadToEndAsync().ConfigureAwait(false); - - var bytes = Convert.FromBase64String(text); - return new MemoryStream(bytes, 0, bytes.Length, false, true); + System.IO.File.Delete(brandingOptions.SplashscreenLocation); + brandingOptions.SplashscreenLocation = null; + _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); } - private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) - { - int? width = null; - int? height = null; - string? blurhash = null; - long length = 0; + return NoContent(); + } - try - { - if (info.IsLocalFile) - { - var fileInfo = _fileSystem.GetFileInfo(info.Path); - length = fileInfo.Length; - - blurhash = info.BlurHash; - width = info.Width; - height = info.Height; - - if (width <= 0 || height <= 0) - { - width = null; - height = null; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image information for {Item}", item.Name); - } + private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) + { + using var reader = new StreamReader(inputStream); + var text = await reader.ReadToEndAsync().ConfigureAwait(false); - try - { - return new ImageInfo - { - Path = info.Path, - ImageIndex = imageIndex, - ImageType = info.Type, - ImageTag = _imageProcessor.GetImageCacheTag(item, info), - Size = length, - BlurHash = blurhash, - Width = width, - Height = height - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image information for {Path}", info.Path); - return null; - } - } + var bytes = Convert.FromBase64String(text); + return new MemoryStream(bytes, 0, bytes.Length, false, true); + } - private async Task<ActionResult> GetImageInternal( - Guid itemId, - ImageType imageType, - int? imageIndex, - string? tag, - ImageFormat? format, - int? maxWidth, - int? maxHeight, - double? percentPlayed, - int? unplayedCount, - int? width, - int? height, - int? quality, - int? fillWidth, - int? fillHeight, - int? blur, - string? backgroundColor, - string? foregroundLayer, - BaseItem? item, - ItemImageInfo? imageInfo = null) - { - if (percentPlayed.HasValue) + private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) + { + int? width = null; + int? height = null; + string? blurhash = null; + long length = 0; + + try + { + if (info.IsLocalFile) { - if (percentPlayed.Value <= 0) - { - percentPlayed = null; - } - else if (percentPlayed.Value >= 100) + var fileInfo = _fileSystem.GetFileInfo(info.Path); + length = fileInfo.Length; + + blurhash = info.BlurHash; + width = info.Width; + height = info.Height; + + if (width <= 0 || height <= 0) { - percentPlayed = null; + width = null; + height = null; } } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image information for {Item}", item.Name); + } - if (percentPlayed.HasValue) - { - unplayedCount = null; - } + try + { + return new ImageInfo + { + Path = info.Path, + ImageIndex = imageIndex, + ImageType = info.Type, + ImageTag = _imageProcessor.GetImageCacheTag(item, info), + Size = length, + BlurHash = blurhash, + Width = width, + Height = height + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image information for {Path}", info.Path); + return null; + } + } - if (unplayedCount.HasValue - && unplayedCount.Value <= 0) + private async Task<ActionResult> GetImageInternal( + Guid itemId, + ImageType imageType, + int? imageIndex, + string? tag, + ImageFormat? format, + int? maxWidth, + int? maxHeight, + double? percentPlayed, + int? unplayedCount, + int? width, + int? height, + int? quality, + int? fillWidth, + int? fillHeight, + int? blur, + string? backgroundColor, + string? foregroundLayer, + BaseItem? item, + ItemImageInfo? imageInfo = null) + { + if (percentPlayed.HasValue) + { + if (percentPlayed.Value <= 0) { - unplayedCount = null; + percentPlayed = null; } - - if (imageInfo is null) + else if (percentPlayed.Value >= 100) { - imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0); - if (imageInfo is null) - { - return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType)); - } + percentPlayed = null; } + } - var outputFormats = GetOutputFormats(format); + if (percentPlayed.HasValue) + { + unplayedCount = null; + } - TimeSpan? cacheDuration = null; + if (unplayedCount.HasValue + && unplayedCount.Value <= 0) + { + unplayedCount = null; + } - if (!string.IsNullOrEmpty(tag)) + if (imageInfo is null) + { + imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0); + if (imageInfo is null) { - cacheDuration = TimeSpan.FromDays(365); + return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType)); } + } - var responseHeaders = new Dictionary<string, string> - { - { "transferMode.dlna.org", "Interactive" }, - { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" } - }; + var outputFormats = GetOutputFormats(format); - if (!imageInfo.IsLocalFile && item is not null) - { - imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false); - } + TimeSpan? cacheDuration = null; - var options = new ImageProcessingOptions - { - Height = height, - ImageIndex = imageIndex ?? 0, - Image = imageInfo, - Item = item, - ItemId = itemId, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - FillHeight = fillHeight, - FillWidth = fillWidth, - Quality = quality ?? 100, - Width = width, - PercentPlayed = percentPlayed ?? 0, - UnplayedCount = unplayedCount, - Blur = blur, - BackgroundColor = backgroundColor, - ForegroundLayer = foregroundLayer, - SupportedOutputFormats = outputFormats - }; + if (!string.IsNullOrEmpty(tag)) + { + cacheDuration = TimeSpan.FromDays(365); + } - return await GetImageResult( - options, - cacheDuration, - responseHeaders).ConfigureAwait(false); + var responseHeaders = new Dictionary<string, string> + { + { "transferMode.dlna.org", "Interactive" }, + { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" } + }; + + if (!imageInfo.IsLocalFile && item is not null) + { + imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false); } - private ImageFormat[] GetOutputFormats(ImageFormat? format) + var options = new ImageProcessingOptions { - if (format.HasValue) - { - return new[] { format.Value }; - } + Height = height, + ImageIndex = imageIndex ?? 0, + Image = imageInfo, + Item = item, + ItemId = itemId, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + FillHeight = fillHeight, + FillWidth = fillWidth, + Quality = quality ?? 100, + Width = width, + PercentPlayed = percentPlayed ?? 0, + UnplayedCount = unplayedCount, + Blur = blur, + BackgroundColor = backgroundColor, + ForegroundLayer = foregroundLayer, + SupportedOutputFormats = outputFormats + }; + + return await GetImageResult( + options, + cacheDuration, + responseHeaders).ConfigureAwait(false); + } - return GetClientSupportedFormats(); + private ImageFormat[] GetOutputFormats(ImageFormat? format) + { + if (format.HasValue) + { + return new[] { format.Value }; } - private ImageFormat[] GetClientSupportedFormats() + return GetClientSupportedFormats(); + } + + private ImageFormat[] GetClientSupportedFormats() + { + var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept); + for (var i = 0; i < supportedFormats.Length; i++) { - var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept); - for (var i = 0; i < supportedFormats.Length; i++) + // Remove charsets etc. (anything after semi-colon) + var type = supportedFormats[i]; + int index = type.IndexOf(';', StringComparison.Ordinal); + if (index != -1) { - // Remove charsets etc. (anything after semi-colon) - var type = supportedFormats[i]; - int index = type.IndexOf(';', StringComparison.Ordinal); - if (index != -1) - { - supportedFormats[i] = type.Substring(0, index); - } + supportedFormats[i] = type.Substring(0, index); } + } - var acceptParam = Request.Query[HeaderNames.Accept]; + var acceptParam = Request.Query[HeaderNames.Accept]; - var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false); + var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false); - if (!supportsWebP) + if (!supportsWebP) + { + var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); + if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase) + && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase)) { - var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); - if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase) - && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase)) - { - supportsWebP = true; - } + supportsWebP = true; } + } - var formats = new List<ImageFormat>(4); + var formats = new List<ImageFormat>(4); - if (supportsWebP) - { - formats.Add(ImageFormat.Webp); - } + if (supportsWebP) + { + formats.Add(ImageFormat.Webp); + } - formats.Add(ImageFormat.Jpg); - formats.Add(ImageFormat.Png); + formats.Add(ImageFormat.Jpg); + formats.Add(ImageFormat.Png); - if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true)) - { - formats.Add(ImageFormat.Gif); - } + if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true)) + { + formats.Add(ImageFormat.Gif); + } + + return formats.ToArray(); + } - return formats.ToArray(); + private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll) + { + if (requestAcceptTypes.Contains(format.GetMimeType())) + { + return true; } - private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll) + if (acceptAll && requestAcceptTypes.Contains("*/*")) { - if (requestAcceptTypes.Contains(format.GetMimeType())) - { - return true; - } + return true; + } - if (acceptAll && requestAcceptTypes.Contains("*/*")) - { - return true; - } + // Review if this should be jpeg, jpg or both for ImageFormat.Jpg + var normalized = format.ToString().ToLowerInvariant(); + return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase); + } - // Review if this should be jpeg, jpg or both for ImageFormat.Jpg - var normalized = format.ToString().ToLowerInvariant(); - return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase); + private async Task<ActionResult> GetImageResult( + ImageProcessingOptions imageProcessingOptions, + TimeSpan? cacheDuration, + IDictionary<string, string> headers) + { + var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false); + + var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache"); + var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader); + + // if the parsing of the IfModifiedSince header was not successful, disable caching + if (!parsingSuccessful) + { + // disableCaching = true; } - private async Task<ActionResult> GetImageResult( - ImageProcessingOptions imageProcessingOptions, - TimeSpan? cacheDuration, - IDictionary<string, string> headers) + foreach (var (key, value) in headers) { - var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false); + Response.Headers.Add(key, value); + } - var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache"); - var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader); + Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain; + Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture)); + Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept); - // if the parsing of the IfModifiedSince header was not successful, disable caching - if (!parsingSuccessful) + if (disableCaching) + { + Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate"); + Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate"); + } + else + { + if (cacheDuration.HasValue) { - // disableCaching = true; + Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); } - - foreach (var (key, value) in headers) + else { - Response.Headers.Add(key, value); + Response.Headers.Add(HeaderNames.CacheControl, "public"); } - Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain; - Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture)); - Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept); + Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); - if (disableCaching) - { - Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate"); - Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate"); - } - else + // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified + if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue) { - if (cacheDuration.HasValue) - { - Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); - } - else + if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow) { - Response.Headers.Add(HeaderNames.CacheControl, "public"); + Response.StatusCode = StatusCodes.Status304NotModified; + return new ContentResult(); } + } + } - Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); + return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain); + } - // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified - if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue) - { - if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow) - { - Response.StatusCode = StatusCodes.Status304NotModified; - return new ContentResult(); - } - } - } + internal static bool TryGetImageExtensionFromContentType(string? contentType, [NotNullWhen(true)] out string? extension) + { + extension = null; + if (string.IsNullOrEmpty(contentType)) + { + return false; + } - return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain); + if (MediaTypeHeaderValue.TryParse(contentType, out var parsedValue) + && parsedValue.MediaType.HasValue + && MimeTypes.IsImage(parsedValue.MediaType.Value)) + { + extension = MimeTypes.ToExtension(parsedValue.MediaType.Value); + return extension is not null; } + + return false; } } diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 2e0d3cb99e..4dc2a4253d 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; @@ -16,346 +16,352 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The instant mix controller. +/// </summary> +[Route("")] +[Authorize] +public class InstantMixController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + private readonly IMusicManager _musicManager; + /// <summary> - /// The instant mix controller. + /// Initializes a new instance of the <see cref="InstantMixController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class InstantMixController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public InstantMixController( + IUserManager userManager, + IDtoService dtoService, + IMusicManager musicManager, + ILibraryManager libraryManager) { - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly ILibraryManager _libraryManager; - private readonly IMusicManager _musicManager; - - /// <summary> - /// Initializes a new instance of the <see cref="InstantMixController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public InstantMixController( - IUserManager userManager, - IDtoService dtoService, - IMusicManager musicManager, - ILibraryManager libraryManager) - { - _userManager = userManager; - _dtoService = dtoService; - _musicManager = musicManager; - _libraryManager = libraryManager; - } + _userManager = userManager; + _dtoService = dtoService; + _musicManager = musicManager; + _libraryManager = libraryManager; + } - /// <summary> - /// Creates an instant playlist based on a given song. - /// </summary> - /// <param name="id">The item id.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Songs/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Songs/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given album. - /// </summary> - /// <param name="id">The item id.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Albums/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var album = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given album. + /// </summary> + /// <param name="id">The item id.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Albums/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var album = _libraryManager.GetItemById(id); + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given playlist. - /// </summary> - /// <param name="id">The item id.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Playlists/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var playlist = (Playlist)_libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given playlist. + /// </summary> + /// <param name="id">The item id.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Playlists/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var playlist = (Playlist)_libraryManager.GetItemById(id); + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given genre. - /// </summary> - /// <param name="name">The genre name.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("MusicGenres/{name}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName( - [FromRoute, Required] string name, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given genre. + /// </summary> + /// <param name="name">The genre name.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("MusicGenres/{name}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName( + [FromRoute, Required] string name, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given artist. - /// </summary> - /// <param name="id">The item id.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Artists/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given artist. + /// </summary> + /// <param name="id">The item id.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Artists/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given item. - /// </summary> - /// <param name="id">The item id.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Items/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given item. + /// </summary> + /// <param name="id">The item id.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Items/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given artist. - /// </summary> - /// <param name="id">The item id.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Artists/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Obsolete("Use GetInstantMixFromArtists")] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( - [FromQuery, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - return GetInstantMixFromArtists( - id, - userId, - limit, - fields, - enableImages, - enableUserData, - imageTypeLimit, - enableImageTypes); - } + /// <summary> + /// Creates an instant playlist based on a given artist. + /// </summary> + /// <param name="id">The item id.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Artists/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetInstantMixFromArtists")] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( + [FromQuery, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + return GetInstantMixFromArtists( + id, + userId, + limit, + fields, + enableImages, + enableUserData, + imageTypeLimit, + enableImageTypes); + } - /// <summary> - /// Creates an instant playlist based on a given genre. - /// </summary> - /// <param name="id">The item id.</param> - /// <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.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("MusicGenres/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( - [FromQuery, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given genre. + /// </summary> + /// <param name="id">The item id.</param> + /// <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.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("MusicGenres/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( + [FromQuery, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions) - { - var list = items; + private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions) + { + var list = items; - var totalCount = list.Count; + var totalCount = list.Count; - if (limit.HasValue && limit < list.Count) - { - list = list.GetRange(0, limit.Value); - } + if (limit.HasValue && limit < list.Count) + { + list = list.GetRange(0, limit.Value); + } - var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); + var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); - var result = new QueryResult<BaseItemDto>( - 0, - totalCount, - returnList); + var result = new QueryResult<BaseItemDto>( + 0, + totalCount, + returnList); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index b6c5504db5..b030e74dda 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; @@ -18,257 +17,256 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Item lookup controller. +/// </summary> +[Route("")] +[Authorize] +public class ItemLookupController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<ItemLookupController> _logger; + /// <summary> - /// Item lookup controller. + /// Initializes a new instance of the <see cref="ItemLookupController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ItemLookupController : BaseJellyfinApiController + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param> + public ItemLookupController( + IProviderManager providerManager, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILogger<ItemLookupController> logger) { - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; - private readonly ILogger<ItemLookupController> _logger; + _providerManager = providerManager; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="ItemLookupController"/> class. - /// </summary> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param> - public ItemLookupController( - IProviderManager providerManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - ILogger<ItemLookupController> logger) + /// <summary> + /// Get the item's external id info. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">External id info retrieved.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of external id info.</returns> + [HttpGet("Items/{itemId}/ExternalIdInfos")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _providerManager = providerManager; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _logger = logger; + return NotFound(); } - /// <summary> - /// Get the item's external id info. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <response code="200">External id info retrieved.</response> - /// <response code="404">Item not found.</response> - /// <returns>List of external id info.</returns> - [HttpGet("Items/{itemId}/ExternalIdInfos")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - return Ok(_providerManager.GetExternalIdInfos(item)); - } + return Ok(_providerManager.GetExternalIdInfos(item)); + } - /// <summary> - /// Get movie remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Movie remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Movie")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get movie remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Movie remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Movie")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get trailer remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Trailer remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Trailer")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get trailer remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Trailer remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Trailer")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get music video remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Music video remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/MusicVideo")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get music video remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music video remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicVideo")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get series remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Series remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Series")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get series remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Series remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Series")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get box set remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Box set remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/BoxSet")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get box set remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Box set remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/BoxSet")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get music artist remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Music artist remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/MusicArtist")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get music artist remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music artist remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicArtist")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get music album remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Music album remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/MusicAlbum")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get music album remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music album remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicAlbum")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get person remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Person remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Person")] - [Authorize(Policy = Policies.RequiresElevation)] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get person remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Person remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Person")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get book remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Book remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Book")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get book remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Book remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Book")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Applies search criteria to an item and refreshes metadata. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="searchResult">The remote search result.</param> - /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> - /// <response code="204">Item metadata refreshed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="NoContentResult"/>. - /// </returns> - [HttpPost("Items/RemoteSearch/Apply/{itemId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ApplySearchCriteria( - [FromRoute, Required] Guid itemId, - [FromBody, Required] RemoteSearchResult searchResult, - [FromQuery] bool replaceAllImages = true) - { - var item = _libraryManager.GetItemById(itemId); - _logger.LogInformation( - "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}", - item.Id, - item.Name, - searchResult.ProviderIds); + /// <summary> + /// Applies search criteria to an item and refreshes metadata. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="searchResult">The remote search result.</param> + /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> + /// <response code="204">Item metadata refreshed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="NoContentResult"/>. + /// </returns> + [HttpPost("Items/RemoteSearch/Apply/{itemId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ApplySearchCriteria( + [FromRoute, Required] Guid itemId, + [FromBody, Required] RemoteSearchResult searchResult, + [FromQuery] bool replaceAllImages = true) + { + var item = _libraryManager.GetItemById(itemId); + _logger.LogInformation( + "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}", + item.Id, + item.Name, + searchResult.ProviderIds); - // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = searchResult.ProviderIds; - await _providerManager.RefreshFullItem( - item, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = replaceAllImages, - SearchResult = searchResult, - RemoveOldMetadata = true - }, - CancellationToken.None).ConfigureAwait(false); + // Since the refresh process won't erase provider Ids, we need to set this explicitly now. + item.ProviderIds = searchResult.ProviderIds; + await _providerManager.RefreshFullItem( + item, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = replaceAllImages, + SearchResult = searchResult, + RemoveOldMetadata = true + }, + CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 0dc3fbd05a..b8f6e91ad2 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -9,78 +9,77 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Item Refresh Controller. +/// </summary> +[Route("Items")] +[Authorize(Policy = Policies.RequiresElevation)] +public class ItemRefreshController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + /// <summary> - /// Item Refresh Controller. + /// Initializes a new instance of the <see cref="ItemRefreshController"/> class. /// </summary> - [Route("Items")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ItemRefreshController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + public ItemRefreshController( + ILibraryManager libraryManager, + IProviderManager providerManager, + IFileSystem fileSystem) { - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; + _libraryManager = libraryManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + } - /// <summary> - /// Initializes a new instance of the <see cref="ItemRefreshController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> - /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> - public ItemRefreshController( - ILibraryManager libraryManager, - IProviderManager providerManager, - IFileSystem fileSystem) + /// <summary> + /// Refreshes metadata for an item. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param> + /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param> + /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param> + /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param> + /// <response code="204">Item metadata refresh queued.</response> + /// <response code="404">Item to refresh not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("{itemId}/Refresh")] + [Description("Refreshes metadata for an item.")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult RefreshItem( + [FromRoute, Required] Guid itemId, + [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, + [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, + [FromQuery] bool replaceAllMetadata = false, + [FromQuery] bool replaceAllImages = false) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _libraryManager = libraryManager; - _providerManager = providerManager; - _fileSystem = fileSystem; + return NotFound(); } - /// <summary> - /// Refreshes metadata for an item. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param> - /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param> - /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param> - /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param> - /// <response code="204">Item metadata refresh queued.</response> - /// <response code="404">Item to refresh not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpPost("{itemId}/Refresh")] - [Description("Refreshes metadata for an item.")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult RefreshItem( - [FromRoute, Required] Guid itemId, - [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, - [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, - [FromQuery] bool replaceAllMetadata = false, - [FromQuery] bool replaceAllImages = false) + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + MetadataRefreshMode = metadataRefreshMode, + ImageRefreshMode = imageRefreshMode, + ReplaceAllImages = replaceAllImages, + ReplaceAllMetadata = replaceAllMetadata, + ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh + || imageRefreshMode == MetadataRefreshMode.FullRefresh + || replaceAllImages + || replaceAllMetadata, + IsAutomated = false + }; - var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = metadataRefreshMode, - ImageRefreshMode = imageRefreshMode, - ReplaceAllImages = replaceAllImages, - ReplaceAllMetadata = replaceAllMetadata, - ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh - || imageRefreshMode == MetadataRefreshMode.FullRefresh - || replaceAllImages - || replaceAllMetadata, - IsAutomated = false - }; - - _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); - return NoContent(); - } + _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index af3d779f56..504f2fa1d7 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -20,332 +20,386 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Item update controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.RequiresElevation)] +public class ItemUpdateController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly ILocalizationManager _localizationManager; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemUpdateController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ItemUpdateController( + IFileSystem fileSystem, + ILibraryManager libraryManager, + IProviderManager providerManager, + ILocalizationManager localizationManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _providerManager = providerManager; + _localizationManager = localizationManager; + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + } + /// <summary> - /// Item update controller. + /// Updates an item. /// </summary> - [Route("")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ItemUpdateController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="request">The new item properties.</param> + /// <response code="204">Item updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) { - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly ILocalizationManager _localizationManager; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ItemUpdateController"/> class. - /// </summary> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public ItemUpdateController( - IFileSystem fileSystem, - ILibraryManager libraryManager, - IProviderManager providerManager, - ILocalizationManager localizationManager, - IServerConfigurationManager serverConfigurationManager) - { - _libraryManager = libraryManager; - _providerManager = providerManager; - _localizationManager = localizationManager; - _fileSystem = fileSystem; - _serverConfigurationManager = serverConfigurationManager; + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Updates an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="request">The new item properties.</param> - /// <response code="204">Item updated.</response> - /// <response code="404">Item not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpPost("Items/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + var newLockData = request.LockData ?? false; + var isLockedChanged = item.IsLocked != newLockData; - var newLockData = request.LockData ?? false; - var isLockedChanged = item.IsLocked != newLockData; + var series = item as Series; + var displayOrderChanged = series is not null && !string.Equals( + series.DisplayOrder ?? string.Empty, + request.DisplayOrder ?? string.Empty, + StringComparison.OrdinalIgnoreCase); - var series = item as Series; - var displayOrderChanged = series is not null && !string.Equals( - series.DisplayOrder ?? string.Empty, - request.DisplayOrder ?? string.Empty, - StringComparison.OrdinalIgnoreCase); + // Do this first so that metadata savers can pull the updates from the database. + if (request.People is not null) + { + _libraryManager.UpdatePeople( + item, + request.People.Select(x => new PersonInfo + { + Name = x.Name, + Role = x.Role, + Type = x.Type + }).ToList()); + } - // Do this first so that metadata savers can pull the updates from the database. - if (request.People is not null) - { - _libraryManager.UpdatePeople( - item, - request.People.Select(x => new PersonInfo - { - Name = x.Name, - Role = x.Role, - Type = x.Type - }).ToList()); - } + await UpdateItem(request, item).ConfigureAwait(false); - UpdateItem(request, item); + item.OnMetadataChanged(); - item.OnMetadataChanged(); + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + if (isLockedChanged && item.IsFolder) + { + var folder = (Folder)item; - if (isLockedChanged && item.IsFolder) + foreach (var child in folder.GetRecursiveChildren()) { - var folder = (Folder)item; + child.IsLocked = newLockData; + await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + } - foreach (var child in folder.GetRecursiveChildren()) + if (displayOrderChanged) + { + _providerManager.QueueRefresh( + series!.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - child.IsLocked = newLockData; - await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - } - } + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true + }, + RefreshPriority.High); + } - if (displayOrderChanged) - { - _providerManager.QueueRefresh( - series!.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true - }, - RefreshPriority.High); - } + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Gets metadata editor info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Item metadata editor returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpGet("Items/{itemId}/MetadataEditor")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); - /// <summary> - /// Gets metadata editor info for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="200">Item metadata editor returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpGet("Items/{itemId}/MetadataEditor")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - - var info = new MetadataEditorInfo - { - ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(), - ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), - Countries = _localizationManager.GetCountries().ToArray(), - Cultures = _localizationManager.GetCultures().ToArray() - }; - - if (!item.IsVirtualItem - && item is not ICollectionFolder - && item is not UserView - && item is not AggregateFolder - && item is not LiveTvChannel - && item is not IItemByName - && item.SourceType == SourceType.Library) + var info = new MetadataEditorInfo + { + ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(), + ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), + Countries = _localizationManager.GetCountries().ToArray(), + Cultures = _localizationManager.GetCultures().ToArray() + }; + + if (!item.IsVirtualItem + && item is not ICollectionFolder + && item is not UserView + && item is not AggregateFolder + && item is not LiveTvChannel + && item is not IItemByName + && item.SourceType == SourceType.Library) + { + var inheritedContentType = _libraryManager.GetInheritedContentType(item); + var configuredContentType = _libraryManager.GetConfiguredContentType(item); + + if (string.IsNullOrWhiteSpace(inheritedContentType) || + !string.IsNullOrWhiteSpace(configuredContentType)) { - var inheritedContentType = _libraryManager.GetInheritedContentType(item); - var configuredContentType = _libraryManager.GetConfiguredContentType(item); + info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); + info.ContentType = configuredContentType; - if (string.IsNullOrWhiteSpace(inheritedContentType) || - !string.IsNullOrWhiteSpace(configuredContentType)) + if (string.IsNullOrWhiteSpace(inheritedContentType) + || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { - info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); - info.ContentType = configuredContentType; - - if (string.IsNullOrWhiteSpace(inheritedContentType) - || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - { - info.ContentTypeOptions = info.ContentTypeOptions - .Where(i => string.IsNullOrWhiteSpace(i.Value) - || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } + info.ContentTypeOptions = info.ContentTypeOptions + .Where(i => string.IsNullOrWhiteSpace(i.Value) + || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + .ToArray(); } } + } - return info; + return info; + } + + /// <summary> + /// Updates an item's content type. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="contentType">The content type of the item.</param> + /// <response code="204">Item content type updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("Items/{itemId}/ContentType")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Updates an item's content type. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="contentType">The content type of the item.</param> - /// <response code="204">Item content type updated.</response> - /// <response code="404">Item not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpPost("Items/{itemId}/ContentType")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) + var path = item.ContainingFolderPath; + + var types = _serverConfigurationManager.Configuration.ContentTypes + .Where(i => !string.IsNullOrWhiteSpace(i.Name)) + .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (!string.IsNullOrWhiteSpace(contentType)) + { + types.Add(new NameValuePair { - return NotFound(); - } + Name = path, + Value = contentType + }); + } - var path = item.ContainingFolderPath; + _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); + } - var types = _serverConfigurationManager.Configuration.ContentTypes - .Where(i => !string.IsNullOrWhiteSpace(i.Name)) - .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) - .ToList(); + private async Task UpdateItem(BaseItemDto request, BaseItem item) + { + item.Name = request.Name; + item.ForcedSortName = request.ForcedSortName; - if (!string.IsNullOrWhiteSpace(contentType)) - { - types.Add(new NameValuePair - { - Name = path, - Value = contentType - }); - } + item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle; + + item.CriticRating = request.CriticRating; + + item.CommunityRating = request.CommunityRating; + item.IndexNumber = request.IndexNumber; + item.ParentIndexNumber = request.ParentIndexNumber; + item.Overview = request.Overview; + item.Genres = request.Genres; - _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); - _serverConfigurationManager.SaveConfiguration(); - return NoContent(); + if (item is Episode episode) + { + episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber; + episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber; + episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber; } - private void UpdateItem(BaseItemDto request, BaseItem item) + if (request.Height is not null && item is LiveTvChannel channel) { - item.Name = request.Name; - item.ForcedSortName = request.ForcedSortName; + channel.Height = request.Height.Value; + } - item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle; + if (request.Taglines is not null) + { + item.Tagline = request.Taglines.FirstOrDefault(); + } - item.CriticRating = request.CriticRating; + if (request.Studios is not null) + { + item.Studios = request.Studios.Select(x => x.Name).ToArray(); + } - item.CommunityRating = request.CommunityRating; - item.IndexNumber = request.IndexNumber; - item.ParentIndexNumber = request.ParentIndexNumber; - item.Overview = request.Overview; - item.Genres = request.Genres; + if (request.DateCreated.HasValue) + { + item.DateCreated = NormalizeDateTime(request.DateCreated.Value); + } - if (item is Episode episode) - { - episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber; - episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber; - episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber; - } + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; + item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; + item.ProductionYear = request.ProductionYear; - item.Tags = request.Tags; + request.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; + item.OfficialRating = request.OfficialRating; + item.CustomRating = request.CustomRating; - if (request.Taglines is not null) - { - item.Tagline = request.Taglines.FirstOrDefault(); - } + var currentTags = item.Tags; + var newTags = request.Tags; + var removedTags = currentTags.Except(newTags).ToList(); + var addedTags = newTags.Except(currentTags).ToList(); + item.Tags = newTags; - if (request.Studios is not null) + if (item is Series rseries) + { + foreach (Season season in rseries.Children) { - item.Studios = request.Studios.Select(x => x.Name).ToArray(); - } + season.OfficialRating = request.OfficialRating; + season.CustomRating = request.CustomRating; + season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + season.OnMetadataChanged(); + await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - if (request.DateCreated.HasValue) + foreach (Episode ep in season.Children) + { + ep.OfficialRating = request.OfficialRating; + ep.CustomRating = request.CustomRating; + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + ep.OnMetadataChanged(); + await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + } + } + else if (item is Season season) + { + foreach (Episode ep in season.Children) { - item.DateCreated = NormalizeDateTime(request.DateCreated.Value); + ep.OfficialRating = request.OfficialRating; + ep.CustomRating = request.CustomRating; + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + ep.OnMetadataChanged(); + await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } - - item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; - item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; - item.ProductionYear = request.ProductionYear; - item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; - item.CustomRating = request.CustomRating; - - if (request.ProductionLocations is not null) + } + else if (item is MusicAlbum album) + { + foreach (BaseItem track in album.Children) { - item.ProductionLocations = request.ProductionLocations; + track.OfficialRating = request.OfficialRating; + track.CustomRating = request.CustomRating; + track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + track.OnMetadataChanged(); + await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } + } - item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode; - item.PreferredMetadataLanguage = request.PreferredMetadataLanguage; + if (request.ProductionLocations is not null) + { + item.ProductionLocations = request.ProductionLocations; + } - if (item is IHasDisplayOrder hasDisplayOrder) - { - hasDisplayOrder.DisplayOrder = request.DisplayOrder; - } + item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode; + item.PreferredMetadataLanguage = request.PreferredMetadataLanguage; - if (item is IHasAspectRatio hasAspectRatio) - { - hasAspectRatio.AspectRatio = request.AspectRatio; - } + if (item is IHasDisplayOrder hasDisplayOrder) + { + hasDisplayOrder.DisplayOrder = request.DisplayOrder; + } - item.IsLocked = request.LockData ?? false; + if (item is IHasAspectRatio hasAspectRatio) + { + hasAspectRatio.AspectRatio = request.AspectRatio; + } - if (request.LockedFields is not null) - { - item.LockedFields = request.LockedFields; - } + item.IsLocked = request.LockData ?? false; - // Only allow this for series. Runtimes for media comes from ffprobe. - if (item is Series) - { - item.RunTimeTicks = request.RunTimeTicks; - } + if (request.LockedFields is not null) + { + item.LockedFields = request.LockedFields; + } - foreach (var pair in request.ProviderIds.ToList()) + // Only allow this for series. Runtimes for media comes from ffprobe. + if (item is Series) + { + item.RunTimeTicks = request.RunTimeTicks; + } + + foreach (var pair in request.ProviderIds.ToList()) + { + if (string.IsNullOrEmpty(pair.Value)) { - if (string.IsNullOrEmpty(pair.Value)) - { - request.ProviderIds.Remove(pair.Key); - } + request.ProviderIds.Remove(pair.Key); } + } - item.ProviderIds = request.ProviderIds; + item.ProviderIds = request.ProviderIds; - if (item is Video video) - { - video.Video3DFormat = request.Video3DFormat; - } + if (item is Video video) + { + video.Video3DFormat = request.Video3DFormat; + } - if (request.AlbumArtists is not null) + if (request.AlbumArtists is not null) + { + if (item is IHasAlbumArtist hasAlbumArtists) { - if (item is IHasAlbumArtist hasAlbumArtists) - { - hasAlbumArtists.AlbumArtists = request - .AlbumArtists - .Select(i => i.Name) - .ToArray(); - } + hasAlbumArtists.AlbumArtists = request + .AlbumArtists + .Select(i => i.Name) + .ToArray(); } + } - if (request.ArtistItems is not null) + if (request.ArtistItems is not null) + { + if (item is IHasArtist hasArtists) { - if (item is IHasArtist hasArtists) - { - hasArtists.Artists = request - .ArtistItems - .Select(i => i.Name) - .ToArray(); - } + hasArtists.Artists = request + .ArtistItems + .Select(i => i.Name) + .ToArray(); } + } - switch (item) - { - case Audio song: - song.Album = request.Album; - break; - case MusicVideo musicVideo: - musicVideo.Album = request.Album; - break; - case Series series: + switch (item) + { + case Audio song: + song.Album = request.Album; + break; + case MusicVideo musicVideo: + musicVideo.Album = request.Album; + break; + case Series series: { series.Status = GetSeriesStatus(request); @@ -357,93 +411,92 @@ namespace Jellyfin.Api.Controllers break; } - } } + } - private SeriesStatus? GetSeriesStatus(BaseItemDto item) + private SeriesStatus? GetSeriesStatus(BaseItemDto item) + { + if (string.IsNullOrEmpty(item.Status)) { - if (string.IsNullOrEmpty(item.Status)) - { - return null; - } - - return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); + return null; } - private DateTime NormalizeDateTime(DateTime val) - { - return DateTime.SpecifyKind(val, DateTimeKind.Utc); - } + return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); + } - private List<NameValuePair> GetContentTypeOptions(bool isForItem) - { - var list = new List<NameValuePair>(); + private DateTime NormalizeDateTime(DateTime val) + { + return DateTime.SpecifyKind(val, DateTimeKind.Utc); + } - if (isForItem) - { - list.Add(new NameValuePair - { - Name = "Inherit", - Value = string.Empty - }); - } + private List<NameValuePair> GetContentTypeOptions(bool isForItem) + { + var list = new List<NameValuePair>(); + if (isForItem) + { list.Add(new NameValuePair { - Name = "Movies", - Value = "movies" - }); - list.Add(new NameValuePair - { - Name = "Music", - Value = "music" - }); - list.Add(new NameValuePair - { - Name = "Shows", - Value = "tvshows" + Name = "Inherit", + Value = string.Empty }); + } - if (!isForItem) - { - list.Add(new NameValuePair - { - Name = "Books", - Value = "books" - }); - } + list.Add(new NameValuePair + { + Name = "Movies", + Value = "movies" + }); + list.Add(new NameValuePair + { + Name = "Music", + Value = "music" + }); + list.Add(new NameValuePair + { + Name = "Shows", + Value = "tvshows" + }); + if (!isForItem) + { list.Add(new NameValuePair { - Name = "HomeVideos", - Value = "homevideos" - }); - list.Add(new NameValuePair - { - Name = "MusicVideos", - Value = "musicvideos" - }); - list.Add(new NameValuePair - { - Name = "Photos", - Value = "photos" + Name = "Books", + Value = "books" }); + } - if (!isForItem) - { - list.Add(new NameValuePair - { - Name = "MixedContent", - Value = string.Empty - }); - } + list.Add(new NameValuePair + { + Name = "HomeVideos", + Value = "homevideos" + }); + list.Add(new NameValuePair + { + Name = "MusicVideos", + Value = "musicvideos" + }); + list.Add(new NameValuePair + { + Name = "Photos", + Value = "photos" + }); - foreach (var val in list) + if (!isForItem) + { + list.Add(new NameValuePair { - val.Name = _localizationManager.GetLocalizedString(val.Name); - } + Name = "MixedContent", + Value = string.Empty + }); + } - return list; + foreach (var val in list) + { + val.Name = _localizationManager.GetLocalizedString(val.Name); } + + return list; } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 717ddc32b3..80128536da 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,12 +1,11 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -20,854 +19,866 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The items controller. +/// </summary> +[Route("")] +[Authorize] +public class ItemsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; + private readonly IDtoService _dtoService; + private readonly ILogger<ItemsController> _logger; + private readonly ISessionManager _sessionManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemsController"/> 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="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + public ItemsController( + IUserManager userManager, + ILibraryManager libraryManager, + ILocalizationManager localization, + IDtoService dtoService, + ILogger<ItemsController> logger, + ISessionManager sessionManager) + { + _userManager = userManager; + _libraryManager = libraryManager; + _localization = localization; + _dtoService = dtoService; + _logger = logger; + _sessionManager = sessionManager; + } + /// <summary> - /// The items controller. + /// Gets items based on a query. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ItemsController : BaseJellyfinApiController + /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> + /// <param name="isMovie">Optional filter for live tv movies.</param> + /// <param name="isSeries">Optional filter for live tv series.</param> + /// <param name="isNews">Optional filter for live tv news.</param> + /// <param name="isKids">Optional filter for live tv kids.</param> + /// <param name="isSports">Optional filter for live tv sports.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <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="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> + [HttpGet("Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetItems( + [FromQuery] Guid? userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] Guid? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? isPlayed, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly ILocalizationManager _localization; - private readonly IDtoService _dtoService; - private readonly ILogger<ItemsController> _logger; - private readonly ISessionManager _sessionManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ItemsController"/> 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="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - public ItemsController( - IUserManager userManager, - ILibraryManager libraryManager, - ILocalizationManager localization, - IDtoService dtoService, - ILogger<ItemsController> logger, - ISessionManager sessionManager) + var isApiKey = User.GetIsApiKey(); + // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method + userId = RequestHelpers.GetUserId(User, userId); + var user = !isApiKey && !userId.Value.Equals(default) + ? _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException() + : null; + + // beyond this point, we're either using an api key or we have a valid user + if (!isApiKey && user is null) + { + return BadRequest("userId is required"); + } + + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + if (includeItemTypes.Length == 1 + && includeItemTypes[0] == BaseItemKind.BoxSet) + { + parentId = null; + } + + var item = _libraryManager.GetParentItem(parentId, userId); + QueryResult<BaseItem> result; + + if (item is not Folder folder) { - _userManager = userManager; - _libraryManager = libraryManager; - _localization = localization; - _dtoService = dtoService; - _logger = logger; - _sessionManager = sessionManager; + folder = _libraryManager.GetUserRootFolder(); } - /// <summary> - /// Gets items based on a query. - /// </summary> - /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> - /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> - /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> - /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> - /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> - /// <param name="hasTrailer">Optional filter by items with trailers.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="parentIndexNumber">Optional filter by parent index number.</param> - /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> - /// <param name="isHd">Optional filter by items that are HD or not.</param> - /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> - /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> - /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> - /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> - /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> - /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> - /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> - /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> - /// <param name="isMovie">Optional filter for live tv movies.</param> - /// <param name="isSeries">Optional filter for live tv series.</param> - /// <param name="isNews">Optional filter for live tv news.</param> - /// <param name="isKids">Optional filter for live tv kids.</param> - /// <param name="isSports">Optional filter for live tv sports.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> - /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> - /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> - /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> - /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> - /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> - /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="isLocked">Optional filter by items that are locked.</param> - /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> - /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> - /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> - /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> - /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> - /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> - /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> - /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <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="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> - [HttpGet("Items")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetItems( - [FromQuery] Guid? userId, - [FromQuery] string? maxOfficialRating, - [FromQuery] bool? hasThemeSong, - [FromQuery] bool? hasThemeVideo, - [FromQuery] bool? hasSubtitles, - [FromQuery] bool? hasSpecialFeature, - [FromQuery] bool? hasTrailer, - [FromQuery] Guid? adjacentTo, - [FromQuery] int? parentIndexNumber, - [FromQuery] bool? hasParentalRating, - [FromQuery] bool? isHd, - [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, - [FromQuery] bool? isMissing, - [FromQuery] bool? isUnaired, - [FromQuery] double? minCommunityRating, - [FromQuery] double? minCriticRating, - [FromQuery] DateTime? minPremiereDate, - [FromQuery] DateTime? minDateLastSaved, - [FromQuery] DateTime? minDateLastSavedForUser, - [FromQuery] DateTime? maxPremiereDate, - [FromQuery] bool? hasOverview, - [FromQuery] bool? hasImdbId, - [FromQuery] bool? hasTmdbId, - [FromQuery] bool? hasTvdbId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? recursive, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, - [FromQuery] string? minOfficialRating, - [FromQuery] bool? isLocked, - [FromQuery] bool? isPlaceHolder, - [FromQuery] bool? hasOfficialRating, - [FromQuery] bool? collapseBoxSetItems, - [FromQuery] int? minWidth, - [FromQuery] int? minHeight, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) + string? collectionType = null; + if (folder is IHasCollectionType hasCollectionType) { - var isApiKey = User.GetIsApiKey(); - // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method - var user = !isApiKey && userId.HasValue && !userId.Value.Equals(default) - ? _userManager.GetUserById(userId.Value) - : null; - - // beyond this point, we're either using an api key or we have a valid user - if (!isApiKey && user is null) + collectionType = hasCollectionType.CollectionType; + } + + if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + { + recursive = true; + includeItemTypes = new[] { BaseItemKind.Playlist }; + } + + if (item is not UserRootFolder + // api keys can always access all folders + && !isApiKey + // check the item is visible for the user + && !item.IsVisible(user)) + { + _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user!.Username, item.Name); + return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); + } + + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) + { + var query = new InternalItemsQuery(user) { - return BadRequest("userId is required"); - } + IsPlayed = isPlayed, + MediaTypes = mediaTypes, + 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, + ParentIndexNumber = parentIndexNumber, + EnableTotalRecordCount = enableTotalRecordCount, + ExcludeItemIds = excludeItemIds, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), + MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), + MinPremiereDate = minPremiereDate?.ToUniversalTime(), + MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + }; - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + { + query.CollapseBoxSetItems = false; + } - if (includeItemTypes.Length == 1 - && (includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.BoxSet)) + foreach (var filter in filters) { - parentId = null; + switch (filter) + { + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + } } - var item = _libraryManager.GetParentItem(parentId, userId); - QueryResult<BaseItem> result; + // Filter by Series Status + if (seriesStatus.Length != 0) + { + query.SeriesStatuses = seriesStatus; + } - if (item is not Folder folder) + // Exclude Blocked Unrated Items + var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems); + if (blockedUnratedItems is not null) { - folder = _libraryManager.GetUserRootFolder(); + query.BlockUnratedItems = blockedUnratedItems; } - string? collectionType = null; - if (folder is IHasCollectionType hasCollectionType) + // ExcludeLocationTypes + if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) { - collectionType = hasCollectionType.CollectionType; + query.IsVirtualItem = false; } - if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + if (locationTypes.Length > 0 && locationTypes.Length < 4) { - recursive = true; - includeItemTypes = new[] { BaseItemKind.Playlist }; + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); } - if (item is not UserRootFolder - // api keys can always access all folders - && !isApiKey - // check the item is visible for the user - && !item.IsVisible(user)) + // Min official rating + if (!string.IsNullOrWhiteSpace(minOfficialRating)) { - _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user!.Username, item.Name); - return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); + query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating); } - if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) + // Max official rating + if (!string.IsNullOrWhiteSpace(maxOfficialRating)) { - var query = new InternalItemsQuery(user) - { - IsPlayed = isPlayed, - MediaTypes = mediaTypes, - 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, - ParentIndexNumber = parentIndexNumber, - EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = excludeItemIds, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), - MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), - MinPremiereDate = minPremiereDate?.ToUniversalTime(), - MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), - }; - - if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) - { - query.CollapseBoxSetItems = false; - } + query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating); + } - foreach (var filter in filters) + // Artists + if (artists.Length != 0) + { + query.ArtistIds = artists.Select(i => { - switch (filter) + try { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; + return _libraryManager.GetArtist(i, new DtoOptions(false)); } - } - - // Filter by Series Status - if (seriesStatus.Length != 0) - { - query.SeriesStatuses = seriesStatus; - } - - // 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.GetRatingLevel(minOfficialRating); - } - - // Max official rating - if (!string.IsNullOrWhiteSpace(maxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating); - } - - // Artists - if (artists.Length != 0) - { - query.ArtistIds = artists.Select(i => + catch { - try - { - return _libraryManager.GetArtist(i, new DtoOptions(false)); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } + 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) + { + query.AlbumIds = albumIds; + } - // Albums - if (albums.Length != 0) + // Albums + if (albums.Length != 0) + { + query.AlbumIds = albums.SelectMany(i => { - query.AlbumIds = albums.SelectMany(i => - { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); - }).ToArray(); - } + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); + }).ToArray(); + } - // Studios - if (studios.Length != 0) + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - query.StudioIds = studios.Select(i => + try { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - // 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) + return _libraryManager.GetStudio(i); + } + catch { - query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + return null; } - } - - result = folder.GetItems(query); + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); } - else + + // Apply default sorting if none requested + if (query.OrderBy.Count == 0) { - var itemsArray = folder.GetChildren(user, true); - result = new QueryResult<BaseItem>(itemsArray); + // Albums by artist + if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) + { + query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + } } - return new QueryResult<BaseItemDto>( - startIndex, - result.TotalRecordCount, - _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + query.Parent = null; + result = folder.GetItems(query); } - - /// <summary> - /// Gets items based on a query. - /// </summary> - /// <param name="userId">The user id supplied as query parameter.</param> - /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> - /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> - /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> - /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> - /// <param name="hasTrailer">Optional filter by items with trailers.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="parentIndexNumber">Optional filter by parent index number.</param> - /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> - /// <param name="isHd">Optional filter by items that are HD or not.</param> - /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> - /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> - /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> - /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> - /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> - /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> - /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> - /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> - /// <param name="isMovie">Optional filter for live tv movies.</param> - /// <param name="isSeries">Optional filter for live tv series.</param> - /// <param name="isNews">Optional filter for live tv news.</param> - /// <param name="isKids">Optional filter for live tv kids.</param> - /// <param name="isSports">Optional filter for live tv sports.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> - /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> - /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> - /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> - /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> - /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> - /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="isLocked">Optional filter by items that are locked.</param> - /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> - /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> - /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> - /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> - /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> - /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> - /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> - /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <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="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> - [HttpGet("Users/{userId}/Items")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId( - [FromRoute] Guid userId, - [FromQuery] string? maxOfficialRating, - [FromQuery] bool? hasThemeSong, - [FromQuery] bool? hasThemeVideo, - [FromQuery] bool? hasSubtitles, - [FromQuery] bool? hasSpecialFeature, - [FromQuery] bool? hasTrailer, - [FromQuery] Guid? adjacentTo, - [FromQuery] int? parentIndexNumber, - [FromQuery] bool? hasParentalRating, - [FromQuery] bool? isHd, - [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, - [FromQuery] bool? isMissing, - [FromQuery] bool? isUnaired, - [FromQuery] double? minCommunityRating, - [FromQuery] double? minCriticRating, - [FromQuery] DateTime? minPremiereDate, - [FromQuery] DateTime? minDateLastSaved, - [FromQuery] DateTime? minDateLastSavedForUser, - [FromQuery] DateTime? maxPremiereDate, - [FromQuery] bool? hasOverview, - [FromQuery] bool? hasImdbId, - [FromQuery] bool? hasTmdbId, - [FromQuery] bool? hasTvdbId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? recursive, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, - [FromQuery] string? minOfficialRating, - [FromQuery] bool? isLocked, - [FromQuery] bool? isPlaceHolder, - [FromQuery] bool? hasOfficialRating, - [FromQuery] bool? collapseBoxSetItems, - [FromQuery] int? minWidth, - [FromQuery] int? minHeight, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) + else { - return GetItems( - userId, - maxOfficialRating, - hasThemeSong, - hasThemeVideo, - hasSubtitles, - hasSpecialFeature, - hasTrailer, - adjacentTo, - parentIndexNumber, - hasParentalRating, - isHd, - is4K, - locationTypes, - excludeLocationTypes, - isMissing, - isUnaired, - minCommunityRating, - minCriticRating, - minPremiereDate, - minDateLastSaved, - minDateLastSavedForUser, - maxPremiereDate, - hasOverview, - hasImdbId, - hasTmdbId, - hasTvdbId, - isMovie, - isSeries, - isNews, - isKids, - isSports, - excludeItemIds, - startIndex, - limit, - recursive, - searchTerm, - sortOrder, - parentId, - fields, - excludeItemTypes, - includeItemTypes, - filters, - isFavorite, - mediaTypes, - imageTypes, - sortBy, - isPlayed, - genres, - officialRatings, - tags, - years, - enableUserData, - imageTypeLimit, - enableImageTypes, - person, - personIds, - personTypes, - studios, - artists, - excludeArtistIds, - artistIds, - albumArtistIds, - contributingArtistIds, - albums, - albumIds, - ids, - videoTypes, - minOfficialRating, - isLocked, - isPlaceHolder, - hasOfficialRating, - collapseBoxSetItems, - minWidth, - minHeight, - maxWidth, - maxHeight, - is3D, - seriesStatus, - nameStartsWithOrGreater, - nameStartsWith, - nameLessThan, - studioIds, - genreIds, - enableTotalRecordCount, - enableImages); + var itemsArray = folder.GetChildren(user, true); + result = new QueryResult<BaseItem>(itemsArray); } - /// <summary> - /// Gets items based on a query. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="startIndex">The start index.</param> - /// <param name="limit">The item limit.</param> - /// <param name="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> - /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> - /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param> - /// <response code="200">Items returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns> - [HttpGet("Users/{userId}/Items/Resume")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetResumeItems( - [FromRoute, Required] Guid userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true, - [FromQuery] bool excludeActiveSessions = false) - { - var user = _userManager.GetUserById(userId); - var parentIdGuid = parentId ?? Guid.Empty; - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return new QueryResult<BaseItemDto>( + startIndex, + result.TotalRecordCount, + _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + } - var ancestorIds = Array.Empty<Guid>(); + /// <summary> + /// Gets items based on a query. + /// </summary> + /// <param name="userId">The user id supplied as query parameter.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> + /// <param name="isMovie">Optional filter for live tv movies.</param> + /// <param name="isSeries">Optional filter for live tv series.</param> + /// <param name="isNews">Optional filter for live tv news.</param> + /// <param name="isKids">Optional filter for live tv kids.</param> + /// <param name="isSports">Optional filter for live tv sports.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <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="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> + [HttpGet("Users/{userId}/Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId( + [FromRoute] Guid userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] Guid? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? isPlayed, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) + { + return GetItems( + userId, + maxOfficialRating, + hasThemeSong, + hasThemeVideo, + hasSubtitles, + hasSpecialFeature, + hasTrailer, + adjacentTo, + parentIndexNumber, + hasParentalRating, + isHd, + is4K, + locationTypes, + excludeLocationTypes, + isMissing, + isUnaired, + minCommunityRating, + minCriticRating, + minPremiereDate, + minDateLastSaved, + minDateLastSavedForUser, + maxPremiereDate, + hasOverview, + hasImdbId, + hasTmdbId, + hasTvdbId, + isMovie, + isSeries, + isNews, + isKids, + isSports, + excludeItemIds, + startIndex, + limit, + recursive, + searchTerm, + sortOrder, + parentId, + fields, + excludeItemTypes, + includeItemTypes, + filters, + isFavorite, + mediaTypes, + imageTypes, + sortBy, + isPlayed, + genres, + officialRatings, + tags, + years, + enableUserData, + imageTypeLimit, + enableImageTypes, + person, + personIds, + personTypes, + studios, + artists, + excludeArtistIds, + artistIds, + albumArtistIds, + contributingArtistIds, + albums, + albumIds, + ids, + videoTypes, + minOfficialRating, + isLocked, + isPlaceHolder, + hasOfficialRating, + collapseBoxSetItems, + minWidth, + minHeight, + maxWidth, + maxHeight, + is3D, + seriesStatus, + nameStartsWithOrGreater, + nameStartsWith, + nameLessThan, + studioIds, + genreIds, + enableTotalRecordCount, + enableImages); + } - var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes); - if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0) - { - ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) - .Where(i => i is Folder) - .Where(i => !excludeFolderIds.Contains(i.Id)) - .Select(i => i.Id) - .ToArray(); - } + /// <summary> + /// Gets items based on a query. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="startIndex">The start index.</param> + /// <param name="limit">The item limit.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param> + /// <response code="200">Items returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns> + [HttpGet("Users/{userId}/Items/Resume")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetResumeItems( + [FromRoute, Required] Guid userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true, + [FromQuery] bool excludeActiveSessions = false) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } - var excludeItemIds = Array.Empty<Guid>(); - if (excludeActiveSessions) - { - excludeItemIds = _sessionManager.Sessions - .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null) - .Select(s => s.NowPlayingItem.Id) - .ToArray(); - } + var parentIdGuid = parentId ?? Guid.Empty; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, - IsResumable = true, - StartIndex = startIndex, - Limit = limit, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions, - MediaTypes = mediaTypes, - IsVirtualItem = false, - CollapseBoxSetItems = false, - EnableTotalRecordCount = enableTotalRecordCount, - AncestorIds = ancestorIds, - IncludeItemTypes = includeItemTypes, - ExcludeItemTypes = excludeItemTypes, - SearchTerm = searchTerm, - ExcludeItemIds = excludeItemIds - }); + var ancestorIds = Array.Empty<Guid>(); - var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user); + var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes); + if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0) + { + ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) + .Where(i => i is Folder) + .Where(i => !excludeFolderIds.Contains(i.Id)) + .Select(i => i.Id) + .ToArray(); + } - return new QueryResult<BaseItemDto>( - startIndex, - itemsResult.TotalRecordCount, - returnItems); + var excludeItemIds = Array.Empty<Guid>(); + if (excludeActiveSessions) + { + excludeItemIds = _sessionManager.Sessions + .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null) + .Select(s => s.NowPlayingItem.Id) + .ToArray(); } + + var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, + IsResumable = true, + StartIndex = startIndex, + Limit = limit, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = dtoOptions, + MediaTypes = mediaTypes, + IsVirtualItem = false, + CollapseBoxSetItems = false, + EnableTotalRecordCount = enableTotalRecordCount, + AncestorIds = ancestorIds, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + SearchTerm = searchTerm, + ExcludeItemIds = excludeItemIds + }); + + var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user); + + return new QueryResult<BaseItemDto>( + startIndex, + itemsResult.TotalRecordCount, + returnItems); } } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 196d509fbc..46c0a8d527 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -4,18 +4,18 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; using System.Linq; -using System.Net; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryDtos; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -37,773 +37,811 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Library Controller. +/// </summary> +[Route("")] +public class LibraryController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IActivityManager _activityManager; + private readonly ILocalizationManager _localization; + private readonly ILibraryMonitor _libraryMonitor; + private readonly ILogger<LibraryController> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Library Controller. + /// Initializes a new instance of the <see cref="LibraryController"/> class. /// </summary> - [Route("")] - public class LibraryController : BaseJellyfinApiController + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> 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="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> + /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public LibraryController( + IProviderManager providerManager, + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IActivityManager activityManager, + ILocalizationManager localization, + ILibraryMonitor libraryMonitor, + ILogger<LibraryController> logger, + IServerConfigurationManager serverConfigurationManager) { - private readonly IProviderManager _providerManager; - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IActivityManager _activityManager; - private readonly ILocalizationManager _localization; - private readonly ILibraryMonitor _libraryMonitor; - private readonly ILogger<LibraryController> _logger; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="LibraryController"/> class. - /// </summary> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> 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="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> - /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public LibraryController( - IProviderManager providerManager, - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IActivityManager activityManager, - ILocalizationManager localization, - ILibraryMonitor libraryMonitor, - ILogger<LibraryController> logger, - IServerConfigurationManager serverConfigurationManager) - { - _providerManager = providerManager; - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _activityManager = activityManager; - _localization = localization; - _libraryMonitor = libraryMonitor; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; - } - - /// <summary> - /// Get the original file of an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="200">File stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns> - [HttpGet("Items/{itemId}/File")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesFile("video/*", "audio/*")] - public ActionResult GetFile([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + _providerManager = providerManager; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _activityManager = activityManager; + _localization = localization; + _libraryMonitor = libraryMonitor; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); + /// <summary> + /// Get the original file of an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">File stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns> + [HttpGet("Items/{itemId}/File")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile("video/*", "audio/*")] + public ActionResult GetFile([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets critic review for an item. - /// </summary> - /// <response code="200">Critic reviews returned.</response> - /// <returns>The list of critic reviews.</returns> - [HttpGet("Items/{itemId}/CriticReviews")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews() + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); + } + + /// <summary> + /// Gets critic review for an item. + /// </summary> + /// <response code="200">Critic reviews returned.</response> + /// <returns>The list of critic reviews.</returns> + [HttpGet("Items/{itemId}/CriticReviews")] + [Authorize] + [Obsolete("This endpoint is obsolete.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews() + { + return new QueryResult<BaseItemDto>(); + } + + /// <summary> + /// Get theme songs for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme songs returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme songs.</returns> + [HttpGet("Items/{itemId}/ThemeSongs")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<ThemeMediaResult> GetThemeSongs( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var item = itemId.Equals(default) + ? (userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); + + if (item is null) { - return new QueryResult<BaseItemDto>(); + return NotFound("Item not found."); } - /// <summary> - /// Get theme songs for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> - /// <response code="200">Theme songs returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The item theme songs.</returns> - [HttpGet("Items/{itemId}/ThemeSongs")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<ThemeMediaResult> GetThemeSongs( - [FromRoute, Required] Guid itemId, - [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); + IEnumerable<BaseItem> themeItems; - if (item is null) + while (true) + { + themeItems = item.GetThemeSongs(); + + if (themeItems.Any() || !inheritFromParent) { - return NotFound("Item not found."); + break; } - IEnumerable<BaseItem> themeItems; - - while (true) + var parent = item.GetParent(); + if (parent is null) { - themeItems = item.GetThemeSongs(); + break; + } - if (themeItems.Any() || !inheritFromParent) - { - break; - } + item = parent; + } - var parent = item.GetParent(); - if (parent is null) - { - break; - } + var dtoOptions = new DtoOptions().AddClientFields(User); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); - item = parent; - } + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// <summary> + /// Get theme videos for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme videos returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme videos.</returns> + [HttpGet("Items/{itemId}/ThemeVideos")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<ThemeMediaResult> GetThemeVideos( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var item = itemId.Equals(default) + ? (userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound("Item not found."); + } - var dtoOptions = new DtoOptions().AddClientFields(User); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); + IEnumerable<BaseItem> themeItems; - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// <summary> - /// Get theme videos for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> - /// <response code="200">Theme videos returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The item theme videos.</returns> - [HttpGet("Items/{itemId}/ThemeVideos")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<ThemeMediaResult> GetThemeVideos( - [FromRoute, Required] Guid itemId, - [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); + while (true) + { + themeItems = item.GetThemeVideos(); - if (item is null) + if (themeItems.Any() || !inheritFromParent) { - return NotFound("Item not found."); + break; } - IEnumerable<BaseItem> themeItems; - - while (true) + var parent = item.GetParent(); + if (parent is null) { - themeItems = item.GetThemeVideos(); + break; + } - if (themeItems.Any() || !inheritFromParent) - { - break; - } + item = parent; + } - var parent = item.GetParent(); - if (parent is null) - { - break; - } + var dtoOptions = new DtoOptions().AddClientFields(User); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); - item = parent; - } + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } - var dtoOptions = new DtoOptions().AddClientFields(User); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); + /// <summary> + /// Get theme songs and videos for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme songs and videos returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme videos.</returns> + [HttpGet("Items/{itemId}/ThemeMedia")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<AllThemeMediaResult> GetThemeMedia( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var themeSongs = GetThemeSongs( + itemId, + userId, + inheritFromParent); - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// <summary> - /// Get theme songs and videos for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> - /// <response code="200">Theme songs and videos returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The item theme videos.</returns> - [HttpGet("Items/{itemId}/ThemeMedia")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<AllThemeMediaResult> GetThemeMedia( - [FromRoute, Required] Guid itemId, - [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) - { - var themeSongs = GetThemeSongs( - itemId, - userId, - inheritFromParent); - - var themeVideos = GetThemeVideos( - itemId, - userId, - inheritFromParent); - - return new AllThemeMediaResult - { - ThemeSongsResult = themeSongs?.Value, - ThemeVideosResult = themeVideos?.Value, - SoundtrackSongsResult = new ThemeMediaResult() - }; - } - - /// <summary> - /// Starts a library scan. - /// </summary> - /// <response code="204">Library scan started.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Refresh")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RefreshLibrary() - { - try - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error refreshing library"); - } + var themeVideos = GetThemeVideos( + itemId, + userId, + inheritFromParent); - return NoContent(); + if (themeSongs.Result is NotFoundObjectResult || themeVideos.Result is NotFoundObjectResult) + { + return NotFound(); } - /// <summary> - /// Deletes an item from the library and filesystem. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="204">Item deleted.</response> - /// <response code="401">Unauthorized access.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Items/{itemId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItem(Guid itemId) + return new AllThemeMediaResult { - var item = _libraryManager.GetItemById(itemId); - var user = _userManager.GetUserById(User.GetUserId()); + ThemeSongsResult = themeSongs?.Value, + ThemeVideosResult = themeVideos?.Value, + SoundtrackSongsResult = new ThemeMediaResult() + }; + } - if (!item.CanDelete(user)) - { - return Unauthorized("Unauthorized access"); - } + /// <summary> + /// Starts a library scan. + /// </summary> + /// <response code="204">Library scan started.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Refresh")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RefreshLibrary() + { + try + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing library"); + } - _libraryManager.DeleteItem( - item, - new DeleteOptions { DeleteFileLocation = true }, - true); + return NoContent(); + } - return NoContent(); + /// <summary> + /// Deletes an item from the library and filesystem. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Item deleted.</response> + /// <response code="401">Unauthorized access.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Items/{itemId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteItem(Guid itemId) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + var user = !isApiKey && !userId.Equals(default) + ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() + : null; + if (!isApiKey && user is null) + { + return Unauthorized("Unauthorized access"); } - /// <summary> - /// Deletes items from the library and filesystem. - /// </summary> - /// <param name="ids">The item ids.</param> - /// <response code="204">Items deleted.</response> - /// <response code="401">Unauthorized access.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Items")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - if (ids.Length == 0) - { - return NoContent(); - } + return NotFound(); + } - foreach (var i in ids) - { - var item = _libraryManager.GetItemById(i); - var user = _userManager.GetUserById(User.GetUserId()); + if (user is not null && !item.CanDelete(user)) + { + return Unauthorized("Unauthorized access"); + } - if (!item.CanDelete(user)) - { - if (ids.Length > 1) - { - return Unauthorized("Unauthorized access"); - } + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); - continue; - } + return NoContent(); + } - _libraryManager.DeleteItem( - item, - new DeleteOptions { DeleteFileLocation = true }, - true); - } + /// <summary> + /// Deletes items from the library and filesystem. + /// </summary> + /// <param name="ids">The item ids.</param> + /// <response code="204">Items deleted.</response> + /// <response code="401">Unauthorized access.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Items")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + var user = !isApiKey && !userId.Equals(default) + ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() + : null; - return NoContent(); + if (!isApiKey && user is null) + { + return Unauthorized("Unauthorized access"); } - /// <summary> - /// Get item counts. - /// </summary> - /// <param name="userId">Optional. Get counts from a specific user's library.</param> - /// <param name="isFavorite">Optional. Get counts of favorite items.</param> - /// <response code="200">Item counts returned.</response> - /// <returns>Item counts.</returns> - [HttpGet("Items/Counts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<ItemCounts> GetItemCounts( - [FromQuery] Guid? userId, - [FromQuery] bool? isFavorite) + foreach (var i in ids) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var counts = new ItemCounts + var item = _libraryManager.GetItemById(i); + if (item is null) { - AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite), - EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite), - MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite), - SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite), - SongCount = GetCount(BaseItemKind.Audio, user, isFavorite), - MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite), - BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite), - BookCount = GetCount(BaseItemKind.Book, user, isFavorite) - }; - - return counts; - } - - /// <summary> - /// Gets all parents of an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Item parents returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>Item parents.</returns> - [HttpGet("Items/{itemId}/Ancestors")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) - { - var item = _libraryManager.GetItemById(itemId); + return NotFound(); + } - if (item is null) + if (user is not null && !item.CanDelete(user)) { - return NotFound("Item not found"); + return Unauthorized("Unauthorized access"); } - var baseItemDtos = new List<BaseItemDto>(); + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + } + + return NoContent(); + } + + /// <summary> + /// Get item counts. + /// </summary> + /// <param name="userId">Optional. Get counts from a specific user's library.</param> + /// <param name="isFavorite">Optional. Get counts of favorite items.</param> + /// <response code="200">Item counts returned.</response> + /// <returns>Item counts.</returns> + [HttpGet("Items/Counts")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ItemCounts> GetItemCounts( + [FromQuery] Guid? userId, + [FromQuery] bool? isFavorite) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var counts = new ItemCounts + { + AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite), + EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite), + MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite), + SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite), + SongCount = GetCount(BaseItemKind.Audio, user, isFavorite), + MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite), + BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite), + BookCount = GetCount(BaseItemKind.Book, user, isFavorite) + }; + + return counts; + } + + /// <summary> + /// Gets all parents of an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Item parents returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>Item parents.</returns> + [HttpGet("Items/{itemId}/Ancestors")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) + { + var item = _libraryManager.GetItemById(itemId); + userId = RequestHelpers.GetUserId(User, userId); + + if (item is null) + { + return NotFound("Item not found"); + } + + var baseItemDtos = new List<BaseItemDto>(); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions().AddClientFields(User); - BaseItem? parent = item.GetParent(); + var dtoOptions = new DtoOptions().AddClientFields(User); + BaseItem? parent = item.GetParent(); - while (parent is not null) + while (parent is not null) + { + if (user is not null) { - if (user is not null) + parent = TranslateParentItem(parent, user); + if (parent is null) { - parent = TranslateParentItem(parent, user); + break; } - - baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - - parent = parent?.GetParent(); } - return baseItemDtos; + baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); + + parent = parent?.GetParent(); } - /// <summary> - /// Gets a list of physical paths from virtual folders. - /// </summary> - /// <response code="200">Physical paths returned.</response> - /// <returns>List of physical paths.</returns> - [HttpGet("Library/PhysicalPaths")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<string>> GetPhysicalPaths() + return baseItemDtos; + } + + /// <summary> + /// Gets a list of physical paths from virtual folders. + /// </summary> + /// <response code="200">Physical paths returned.</response> + /// <returns>List of physical paths.</returns> + [HttpGet("Library/PhysicalPaths")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<string>> GetPhysicalPaths() + { + return Ok(_libraryManager.RootFolder.Children + .SelectMany(c => c.PhysicalLocations)); + } + + /// <summary> + /// Gets all user media folders. + /// </summary> + /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param> + /// <response code="200">Media folders returned.</response> + /// <returns>List of user media folders.</returns> + [HttpGet("Library/MediaFolders")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden) + { + var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); + + if (isHidden.HasValue) { - return Ok(_libraryManager.RootFolder.Children - .SelectMany(c => c.PhysicalLocations)); + var val = isHidden.Value; + + items = items.Where(i => i.IsHidden == val).ToList(); } - /// <summary> - /// Gets all user media folders. - /// </summary> - /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param> - /// <response code="200">Media folders returned.</response> - /// <returns>List of user media folders.</returns> - [HttpGet("Library/MediaFolders")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden) - { - var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); + var dtoOptions = new DtoOptions().AddClientFields(User); + var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); + return new QueryResult<BaseItemDto>(resultArray); + } - if (isHidden.HasValue) + /// <summary> + /// Reports that new episodes of a series have been added by an external source. + /// </summary> + /// <param name="tvdbId">The tvdbId.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Series/Added", Name = "PostAddedSeries")] + [HttpPost("Library/Series/Updated")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + DtoOptions = new DtoOptions(false) { - var val = isHidden.Value; - - items = items.Where(i => i.IsHidden == val).ToList(); + EnableImages = false } + }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); - var dtoOptions = new DtoOptions().AddClientFields(User); - var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); - return new QueryResult<BaseItemDto>(resultArray); + foreach (var item in series) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); } - /// <summary> - /// Reports that new episodes of a series have been added by an external source. - /// </summary> - /// <param name="tvdbId">The tvdbId.</param> - /// <response code="204">Report success.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Series/Added", Name = "PostAddedSeries")] - [HttpPost("Library/Series/Updated")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId) - { - var series = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); + return NoContent(); + } - foreach (var item in series) + /// <summary> + /// Reports that new movies have been added by an external source. + /// </summary> + /// <param name="tmdbId">The tmdbId.</param> + /// <param name="imdbId">The imdbId.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Movies/Added", Name = "PostAddedMovies")] + [HttpPost("Library/Movies/Updated")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedMovies([FromQuery] string? tmdbId, [FromQuery] string? imdbId) + { + var movies = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + DtoOptions = new DtoOptions(false) { - _libraryMonitor.ReportFileSystemChanged(item.Path); + EnableImages = false } + }); - return NoContent(); + if (!string.IsNullOrWhiteSpace(imdbId)) + { + movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else if (!string.IsNullOrWhiteSpace(tmdbId)) + { + movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else + { + movies = new List<BaseItem>(); } - /// <summary> - /// Reports that new movies have been added by an external source. - /// </summary> - /// <param name="tmdbId">The tmdbId.</param> - /// <param name="imdbId">The imdbId.</param> - /// <response code="204">Report success.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Movies/Added", Name = "PostAddedMovies")] - [HttpPost("Library/Movies/Updated")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedMovies([FromQuery] string? tmdbId, [FromQuery] string? imdbId) + foreach (var item in movies) { - var movies = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Movie }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }); + _libraryMonitor.ReportFileSystemChanged(item.Path); + } - if (!string.IsNullOrWhiteSpace(imdbId)) - { - movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); - } - else if (!string.IsNullOrWhiteSpace(tmdbId)) - { - movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); - } - else - { - movies = new List<BaseItem>(); - } + return NoContent(); + } - foreach (var item in movies) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } + /// <summary> + /// Reports that new movies have been added by an external source. + /// </summary> + /// <param name="dto">The update paths.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Media/Updated")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto) + { + foreach (var item in dto.Updates) + { + _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); + } + + return NoContent(); + } - return NoContent(); + /// <summary> + /// Downloads item media. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Media downloaded.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="FileResult"/> containing the media stream.</returns> + /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception> + [HttpGet("Items/{itemId}/Download")] + [Authorize(Policy = Policies.Download)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile("video/*", "audio/*")] + public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Reports that new movies have been added by an external source. - /// </summary> - /// <param name="dto">The update paths.</param> - /// <response code="204">Report success.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Media/Updated")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto) + var user = _userManager.GetUserById(User.GetUserId()); + + if (user is not null) { - foreach (var item in dto.Updates) + if (!item.CanDownload(user)) { - _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); + throw new ArgumentException("Item does not support downloading"); } - - return NoContent(); - } - - /// <summary> - /// Downloads item media. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="200">Media downloaded.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="FileResult"/> containing the media stream.</returns> - /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception> - [HttpGet("Items/{itemId}/Download")] - [Authorize(Policy = Policies.Download)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesFile("video/*", "audio/*")] - public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) + } + else + { + if (!item.CanDownload()) { - return NotFound(); + throw new ArgumentException("Item does not support downloading"); } + } - var user = _userManager.GetUserById(User.GetUserId()); + if (user is not null) + { + await LogDownloadAsync(item, user).ConfigureAwait(false); + } - if (user is not null) - { - if (!item.CanDownload(user)) - { - throw new ArgumentException("Item does not support downloading"); - } - } - else - { - if (!item.CanDownload()) - { - throw new ArgumentException("Item does not support downloading"); - } - } + // Quotes are valid in linux. They'll possibly cause issues here. + var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal); - if (user is not null) - { - await LogDownloadAsync(item, user).ConfigureAwait(false); - } + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true); + } - // Quotes are valid in linux. They'll possibly cause issues here. - var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal); - - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true); - } - - /// <summary> - /// Gets similar items. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="excludeArtistIds">Exclude artist ids.</param> - /// <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> - /// <response code="200">Similar items returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> - [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] - [HttpGet("Items/{itemId}/Similar")] - [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")] - [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")] - [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")] - [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) - { - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - - if (item is Episode || (item is IItemByName && item is not MusicArtist)) - { - return new QueryResult<BaseItemDto>(); - } + /// <summary> + /// Gets similar items. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="excludeArtistIds">Exclude artist ids.</param> + /// <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> + /// <response code="200">Similar items returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> + [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] + [HttpGet("Items/{itemId}/Similar")] + [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")] + [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")] + [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")] + [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( + [FromRoute, Required] Guid itemId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + { + userId = RequestHelpers.GetUserId(User, userId); + var item = itemId.Equals(default) + ? (userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound(); + } - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User); + if (item is Episode || (item is IItemByName && item is not MusicArtist)) + { + return new QueryResult<BaseItemDto>(); + } - 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 user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User); - 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 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 query = new InternalItemsQuery(user) - { - Genres = item.Genres, - Limit = limit, - IncludeItemTypes = includeItemTypes.ToArray(), - SimilarTo = item, - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie ?? true, - EnableGroupByMetadataKey = isMovie ?? false, - MinSimilarityScore = 2 // A remnant from album/artist scoring - }; - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) + var includeItemTypes = new List<BaseItemKind>(); + if (isMovie.Value) + { + includeItemTypes.Add(BaseItemKind.Movie); + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - query.ExcludeArtistIds = excludeArtistIds; + 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()); + } - List<BaseItem> itemsResult = _libraryManager.GetItemList(query); + var query = new InternalItemsQuery(user) + { + Genres = item.Genres, + Limit = limit, + IncludeItemTypes = includeItemTypes.ToArray(), + SimilarTo = item, + DtoOptions = dtoOptions, + EnableTotalRecordCount = !isMovie ?? true, + EnableGroupByMetadataKey = isMovie ?? false, + MinSimilarityScore = 2 // A remnant from album/artist scoring + }; + + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); + List<BaseItem> itemsResult = _libraryManager.GetItemList(query); - return new QueryResult<BaseItemDto>( - query.StartIndex, - itemsResult.Count, - returnList); - } + var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - /// <summary> - /// Gets the library options info. - /// </summary> - /// <param name="libraryContentType">Library content type.</param> - /// <param name="isNewLibrary">Whether this is a new library.</param> - /// <response code="200">Library options info returned.</response> - /// <returns>Library options info.</returns> - [HttpGet("Libraries/AvailableOptions")] - [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo( - [FromQuery] string? libraryContentType, - [FromQuery] bool isNewLibrary = false) - { - var result = new LibraryOptionsResultDto(); + return new QueryResult<BaseItemDto>( + query.StartIndex, + itemsResult.Count, + returnList); + } - var types = GetRepresentativeItemTypes(libraryContentType); - var typesList = types.ToList(); + /// <summary> + /// Gets the library options info. + /// </summary> + /// <param name="libraryContentType">Library content type.</param> + /// <param name="isNewLibrary">Whether this is a new library.</param> + /// <response code="200">Library options info returned.</response> + /// <returns>Library options info.</returns> + [HttpGet("Libraries/AvailableOptions")] + [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo( + [FromQuery] string? libraryContentType, + [FromQuery] bool isNewLibrary = false) + { + var result = new LibraryOptionsResultDto(); - var plugins = _providerManager.GetAllMetadataPlugins() - .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase)) - .OrderBy(i => typesList.IndexOf(i.ItemType)) - .ToList(); + var types = GetRepresentativeItemTypes(libraryContentType); + var typesList = types.ToList(); - result.MetadataSavers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) - .Select(i => new LibraryOptionInfoDto - { - Name = i.Name, - DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) - }) - .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - result.MetadataReaders = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) - .Select(i => new LibraryOptionInfoDto - { - Name = i.Name, - DefaultEnabled = true - }) - .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - result.SubtitleFetchers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) - .Select(i => new LibraryOptionInfoDto - { - Name = i.Name, - DefaultEnabled = true - }) - .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(); + var plugins = _providerManager.GetAllMetadataPlugins() + .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => typesList.IndexOf(i.ItemType)) + .ToList(); - var typeOptions = new List<LibraryTypeOptionsDto>(); + result.MetadataSavers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); - foreach (var type in types) + result.MetadataReaders = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) + .Select(i => new LibraryOptionInfoDto { - TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); + Name = i.Name, + DefaultEnabled = true + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); - typeOptions.Add(new LibraryTypeOptionsDto - { - Type = type, + result.SubtitleFetchers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = true + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var typeOptions = new List<LibraryTypeOptionsDto>(); + + foreach (var type in types) + { + TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); + + typeOptions.Add(new LibraryTypeOptionsDto + { + Type = type, - MetadataFetchers = plugins + MetadataFetchers = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) .Select(i => new LibraryOptionInfoDto @@ -814,7 +852,7 @@ namespace Jellyfin.Api.Controllers .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(), - ImageFetchers = plugins + ImageFetchers = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) .Select(i => new LibraryOptionInfoDto @@ -825,148 +863,135 @@ namespace Jellyfin.Api.Controllers .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(), - SupportedImageTypes = plugins + SupportedImageTypes = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) .Distinct() .ToArray(), - DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>() - }); - } + DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>() + }); + } - result.TypeOptions = typeOptions.ToArray(); + result.TypeOptions = typeOptions.ToArray(); - return result; - } + return result; + } - private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) + private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) + { + var query = new InternalItemsQuery(user) { - var query = new InternalItemsQuery(user) + IncludeItemTypes = new[] { itemKind }, + Limit = 0, + Recursive = true, + IsVirtualItem = false, + IsFavorite = isFavorite, + DtoOptions = new DtoOptions(false) { - IncludeItemTypes = new[] { itemKind }, - Limit = 0, - Recursive = true, - IsVirtualItem = false, - IsFavorite = isFavorite, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }; + EnableImages = false + } + }; - return _libraryManager.GetItemsResult(query).TotalRecordCount; - } + return _libraryManager.GetItemsResult(query).TotalRecordCount; + } - private BaseItem? TranslateParentItem(BaseItem item, User user) - { - return item.GetParent() is AggregateFolder - ? _libraryManager.GetUserRootFolder().GetChildren(user, true) - .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) - : item; - } + private BaseItem? TranslateParentItem(BaseItem item, User user) + { + return item.GetParent() is AggregateFolder + ? _libraryManager.GetUserRootFolder().GetChildren(user, true) + .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) + : item; + } - private async Task LogDownloadAsync(BaseItem item, User user) + private async Task LogDownloadAsync(BaseItem item, User user) + { + try { - try - { - await _activityManager.CreateAsync(new ActivityLog( - string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), - "UserDownloadingContent", - User.GetUserId()) - { - ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), - }).ConfigureAwait(false); - } - catch + await _activityManager.CreateAsync(new ActivityLog( + string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), + "UserDownloadingContent", + User.GetUserId()) { - // Logged at lower levels - } + ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + }).ConfigureAwait(false); } - - private static string[] GetRepresentativeItemTypes(string? contentType) + catch { - return contentType switch - { - CollectionType.BoxSets => new[] { "BoxSet" }, - CollectionType.Playlists => new[] { "Playlist" }, - CollectionType.Movies => new[] { "Movie" }, - CollectionType.TvShows => new[] { "Series", "Season", "Episode" }, - CollectionType.Books => new[] { "Book" }, - CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, - CollectionType.HomeVideos => new[] { "Video", "Photo" }, - CollectionType.Photos => new[] { "Video", "Photo" }, - CollectionType.MusicVideos => new[] { "MusicVideo" }, - _ => new[] { "Series", "Season", "Episode", "Movie" } - }; - } - - private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) - { - if (isNewLibrary) - { - return false; - } + // Logged at lower levels + } + } - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - .ToArray(); + private static string[] GetRepresentativeItemTypes(string? contentType) + { + return contentType switch + { + CollectionType.BoxSets => new[] { "BoxSet" }, + CollectionType.Playlists => new[] { "Playlist" }, + CollectionType.Movies => new[] { "Movie" }, + CollectionType.TvShows => new[] { "Series", "Season", "Episode" }, + CollectionType.Books => new[] { "Book" }, + CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, + CollectionType.HomeVideos => new[] { "Video", "Photo" }, + CollectionType.Photos => new[] { "Video", "Photo" }, + CollectionType.MusicVideos => new[] { "MusicVideo" }, + _ => new[] { "Series", "Season", "Episode", "Movie" } + }; + } - return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparison.OrdinalIgnoreCase)); + private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) + { + if (isNewLibrary) + { + return false; } - private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparison.OrdinalIgnoreCase)); + } + + private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) { - if (isNewLibrary) + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase)); - } + } - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - return metadataOptions.Length == 0 - || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); } - private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) - { - if (isNewLibrary) - { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); + var metadataOptions = _serverConfigurationManager.GetMetadataOptionsForType(type); + return metadataOptions is null || !metadataOptions.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); + } - if (metadataOptions.Length == 0) + private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) + { + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) { - return true; + return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); } - return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); } + + var metadataOptions = _serverConfigurationManager.GetMetadataOptionsForType(type); + return metadataOptions is null || !metadataOptions.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); } } diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 1c23940556..b012ff42eb 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -20,308 +20,307 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The library structure controller. +/// </summary> +[Route("Library/VirtualFolders")] +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class LibraryStructureController : BaseJellyfinApiController { + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly ILibraryMonitor _libraryMonitor; + /// <summary> - /// The library structure controller. + /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. /// </summary> - [Route("Library/VirtualFolders")] - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class LibraryStructureController : BaseJellyfinApiController + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + public LibraryStructureController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor) { - private readonly IServerApplicationPaths _appPaths; - private readonly ILibraryManager _libraryManager; - private readonly ILibraryMonitor _libraryMonitor; - - /// <summary> - /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> - public LibraryStructureController( - IServerConfigurationManager serverConfigurationManager, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) - { - _appPaths = serverConfigurationManager.ApplicationPaths; - _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; - } + _appPaths = serverConfigurationManager.ApplicationPaths; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + } + + /// <summary> + /// Gets all virtual folders. + /// </summary> + /// <response code="200">Virtual folders retrieved.</response> + /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders() + { + return _libraryManager.GetVirtualFolders(true); + } + + /// <summary> + /// Adds a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="collectionType">The type of the collection.</param> + /// <param name="paths">The paths of the virtual folder.</param> + /// <param name="libraryOptionsDto">The library options.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder added.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddVirtualFolder( + [FromQuery] string? name, + [FromQuery] CollectionTypeOptions? collectionType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, + [FromBody] AddVirtualFolderDto? libraryOptionsDto, + [FromQuery] bool refreshLibrary = false) + { + var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions(); - /// <summary> - /// Gets all virtual folders. - /// </summary> - /// <response code="200">Virtual folders retrieved.</response> - /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders() + if (paths is not null && paths.Length > 0) { - return _libraryManager.GetVirtualFolders(true); + libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); } - /// <summary> - /// Adds a virtual folder. - /// </summary> - /// <param name="name">The name of the virtual folder.</param> - /// <param name="collectionType">The type of the collection.</param> - /// <param name="paths">The paths of the virtual folder.</param> - /// <param name="libraryOptionsDto">The library options.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <response code="204">Folder added.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddVirtualFolder( - [FromQuery] string? name, - [FromQuery] CollectionTypeOptions? collectionType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, - [FromBody] AddVirtualFolderDto? libraryOptionsDto, - [FromQuery] bool refreshLibrary = false) - { - var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions(); + await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); - if (paths is not null && paths.Length > 0) - { - libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); - } + return NoContent(); + } - await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); + /// <summary> + /// Removes a virtual folder. + /// </summary> + /// <param name="name">The name of the folder.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder removed.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveVirtualFolder( + [FromQuery] string? name, + [FromQuery] bool refreshLibrary = false) + { + await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); + /// <summary> + /// Renames a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="newName">The new name.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder renamed.</response> + /// <response code="404">Library doesn't exist.</response> + /// <response code="409">Library already exists.</response> + /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> + /// <exception cref="ArgumentNullException">The new name may not be null.</exception> + [HttpPost("Name")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public ActionResult RenameVirtualFolder( + [FromQuery] string? name, + [FromQuery] string? newName, + [FromQuery] bool refreshLibrary = false) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); } - /// <summary> - /// Removes a virtual folder. - /// </summary> - /// <param name="name">The name of the folder.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <response code="204">Folder removed.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveVirtualFolder( - [FromQuery] string? name, - [FromQuery] bool refreshLibrary = false) + if (string.IsNullOrWhiteSpace(newName)) { - await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); - return NoContent(); + throw new ArgumentNullException(nameof(newName)); } - /// <summary> - /// Renames a virtual folder. - /// </summary> - /// <param name="name">The name of the virtual folder.</param> - /// <param name="newName">The new name.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <response code="204">Folder renamed.</response> - /// <response code="404">Library doesn't exist.</response> - /// <response code="409">Library already exists.</response> - /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> - /// <exception cref="ArgumentNullException">The new name may not be null.</exception> - [HttpPost("Name")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public ActionResult RenameVirtualFolder( - [FromQuery] string? name, - [FromQuery] string? newName, - [FromQuery] bool refreshLibrary = false) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } + var rootFolderPath = _appPaths.DefaultUserViewsPath; - if (string.IsNullOrWhiteSpace(newName)) - { - throw new ArgumentNullException(nameof(newName)); - } + var currentPath = Path.Combine(rootFolderPath, name); + var newPath = Path.Combine(rootFolderPath, newName); - var rootFolderPath = _appPaths.DefaultUserViewsPath; + if (!Directory.Exists(currentPath)) + { + return NotFound("The media collection does not exist."); + } - var currentPath = Path.Combine(rootFolderPath, name); - var newPath = Path.Combine(rootFolderPath, newName); + if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) + { + return Conflict($"The media library already exists at {newPath}."); + } - if (!Directory.Exists(currentPath)) - { - return NotFound("The media collection does not exist."); - } + _libraryMonitor.Stop(); - if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) + try + { + // Changing capitalization. Handle windows case insensitivity + if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) { - return Conflict($"The media library already exists at {newPath}."); + var tempPath = Path.Combine( + rootFolderPath, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + Directory.Move(currentPath, tempPath); + currentPath = tempPath; } - _libraryMonitor.Stop(); + Directory.Move(currentPath, newPath); + } + finally + { + CollectionFolder.OnCollectionFolderChange(); - try + Task.Run(async () => { - // Changing capitalization. Handle windows case insensitivity - if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) + // No need to start if scanning the library because it will handle it + if (refreshLibrary) { - var tempPath = Path.Combine( - rootFolderPath, - Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - Directory.Move(currentPath, tempPath); - currentPath = tempPath; + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); } - - Directory.Move(currentPath, newPath); - } - finally - { - CollectionFolder.OnCollectionFolderChange(); - - Task.Run(async () => + else { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); - } - }); - } - - return NoContent(); + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); } - /// <summary> - /// Add a media path to a library. - /// </summary> - /// <param name="mediaPathDto">The media path dto.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <returns>A <see cref="NoContentResult"/>.</returns> - /// <response code="204">Media path added.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> - [HttpPost("Paths")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult AddMediaPath( - [FromBody, Required] MediaPathDto mediaPathDto, - [FromQuery] bool refreshLibrary = false) - { - _libraryMonitor.Stop(); + return NoContent(); + } - try - { - var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null.")); + /// <summary> + /// Add a media path to a library. + /// </summary> + /// <param name="mediaPathDto">The media path dto.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path added.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddMediaPath( + [FromBody, Required] MediaPathDto mediaPathDto, + [FromQuery] bool refreshLibrary = false) + { + _libraryMonitor.Stop(); - _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); - } - finally - { - Task.Run(async () => - { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); - } - }); - } + try + { + var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null.")); - return NoContent(); + _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); } - - /// <summary> - /// Updates a media path. - /// </summary> - /// <param name="mediaPathRequestDto">The name of the library and path infos.</param> - /// <returns>A <see cref="NoContentResult"/>.</returns> - /// <response code="204">Media path updated.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> - [HttpPost("Paths/Update")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto) + finally { - if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name)) + Task.Run(async () => { - throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty"); - } + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } - _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo); - return NoContent(); + /// <summary> + /// Updates a media path. + /// </summary> + /// <param name="mediaPathRequestDto">The name of the library and path infos.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path updated.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths/Update")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto) + { + if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name)) + { + throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty"); } - /// <summary> - /// Remove a media path. - /// </summary> - /// <param name="name">The name of the library.</param> - /// <param name="path">The path to remove.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <returns>A <see cref="NoContentResult"/>.</returns> - /// <response code="204">Media path removed.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> - [HttpDelete("Paths")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveMediaPath( - [FromQuery] string? name, - [FromQuery] string? path, - [FromQuery] bool refreshLibrary = false) + _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo); + return NoContent(); + } + + /// <summary> + /// Remove a media path. + /// </summary> + /// <param name="name">The name of the library.</param> + /// <param name="path">The path to remove.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path removed.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpDelete("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveMediaPath( + [FromQuery] string? name, + [FromQuery] string? path, + [FromQuery] bool refreshLibrary = false) + { + if (string.IsNullOrWhiteSpace(name)) { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } + throw new ArgumentNullException(nameof(name)); + } - _libraryMonitor.Stop(); + _libraryMonitor.Stop(); - try - { - _libraryManager.RemoveMediaPath(name, path); - } - finally + try + { + _libraryManager.RemoveMediaPath(name, path); + } + finally + { + Task.Run(async () => { - Task.Run(async () => + // No need to start if scanning the library because it will handle it + if (refreshLibrary) { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); - } - }); - } - - return NoContent(); + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); } - /// <summary> - /// Update library options. - /// </summary> - /// <param name="request">The library name and options.</param> - /// <response code="204">Library updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("LibraryOptions")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateLibraryOptions( - [FromBody] UpdateLibraryOptionsDto request) - { - var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); + return NoContent(); + } - collectionFolder.UpdateLibraryOptions(request.LibraryOptions); - return NoContent(); - } + /// <summary> + /// Update library options. + /// </summary> + /// <param name="request">The library name and options.</param> + /// <response code="204">Library updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("LibraryOptions")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateLibraryOptions( + [FromBody] UpdateLibraryOptionsDto request) + { + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); + + collectionFolder.UpdateLibraryOptions(request.LibraryOptions); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 5228e0babf..267ba4afb4 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -17,14 +17,12 @@ using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -35,1200 +33,1176 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Live tv controller. +/// </summary> +public class LiveTvController : BaseJellyfinApiController { + private readonly ILiveTvManager _liveTvManager; + private readonly IUserManager _userManager; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IConfigurationManager _configurationManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ISessionManager _sessionManager; + /// <summary> - /// Live tv controller. + /// Initializes a new instance of the <see cref="LiveTvController"/> class. /// </summary> - public class LiveTvController : BaseJellyfinApiController + /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> 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="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + public LiveTvController( + ILiveTvManager liveTvManager, + IUserManager userManager, + IHttpClientFactory httpClientFactory, + ILibraryManager libraryManager, + IDtoService dtoService, + IMediaSourceManager mediaSourceManager, + IConfigurationManager configurationManager, + TranscodingJobHelper transcodingJobHelper, + ISessionManager sessionManager) { - private readonly ILiveTvManager _liveTvManager; - private readonly IUserManager _userManager; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IConfigurationManager _configurationManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly ISessionManager _sessionManager; - - /// <summary> - /// Initializes a new instance of the <see cref="LiveTvController"/> class. - /// </summary> - /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> 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="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - public LiveTvController( - ILiveTvManager liveTvManager, - IUserManager userManager, - IHttpClientFactory httpClientFactory, - ILibraryManager libraryManager, - IDtoService dtoService, - IMediaSourceManager mediaSourceManager, - IConfigurationManager configurationManager, - TranscodingJobHelper transcodingJobHelper, - ISessionManager sessionManager) - { - _liveTvManager = liveTvManager; - _userManager = userManager; - _httpClientFactory = httpClientFactory; - _libraryManager = libraryManager; - _dtoService = dtoService; - _mediaSourceManager = mediaSourceManager; - _configurationManager = configurationManager; - _transcodingJobHelper = transcodingJobHelper; - _sessionManager = sessionManager; - } + _liveTvManager = liveTvManager; + _userManager = userManager; + _httpClientFactory = httpClientFactory; + _libraryManager = libraryManager; + _dtoService = dtoService; + _mediaSourceManager = mediaSourceManager; + _configurationManager = configurationManager; + _transcodingJobHelper = transcodingJobHelper; + _sessionManager = sessionManager; + } - /// <summary> - /// Gets available live tv services. - /// </summary> - /// <response code="200">Available live tv services returned.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the available live tv services. - /// </returns> - [HttpGet("Info")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<LiveTvInfo> GetLiveTvInfo() - { - return _liveTvManager.GetLiveTvInfo(CancellationToken.None); - } + /// <summary> + /// Gets available live tv services. + /// </summary> + /// <response code="200">Available live tv services returned.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the available live tv services. + /// </returns> + [HttpGet("Info")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult<LiveTvInfo> GetLiveTvInfo() + { + return _liveTvManager.GetLiveTvInfo(CancellationToken.None); + } - /// <summary> - /// Gets available live tv channels. - /// </summary> - /// <param name="type">Optional. Filter by channel type.</param> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="isMovie">Optional. Filter for movies.</param> - /// <param name="isSeries">Optional. Filter for series.</param> - /// <param name="isNews">Optional. Filter for news.</param> - /// <param name="isKids">Optional. Filter for kids.</param> - /// <param name="isSports">Optional. Filter for sports.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param> - /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param> - /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="sortBy">Optional. Key to sort by.</param> - /// <param name="sortOrder">Optional. Sort order.</param> - /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param> - /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param> - /// <response code="200">Available live tv channels returned.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the resulting available live tv channels. - /// </returns> - [HttpGet("Channels")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<QueryResult<BaseItemDto>> GetLiveTvChannels( - [FromQuery] ChannelType? type, - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] int? limit, - [FromQuery] bool? isFavorite, - [FromQuery] bool? isLiked, - [FromQuery] bool? isDisliked, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] SortOrder? sortOrder, - [FromQuery] bool enableFavoriteSorting = false, - [FromQuery] bool addCurrentProgram = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - var channelResult = _liveTvManager.GetInternalChannels( - new LiveTvChannelQuery - { - ChannelType = type, - UserId = userId ?? Guid.Empty, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - IsLiked = isLiked, - IsDisliked = isDisliked, - EnableFavoriteSorting = enableFavoriteSorting, - IsMovie = isMovie, - IsSeries = isSeries, - IsNews = isNews, - IsKids = isKids, - IsSports = isSports, - SortBy = sortBy, - SortOrder = sortOrder ?? SortOrder.Ascending, - AddCurrentProgram = addCurrentProgram - }, - dtoOptions, - CancellationToken.None); - - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var fieldsList = dtoOptions.Fields.ToList(); - fieldsList.Remove(ItemFields.CanDelete); - fieldsList.Remove(ItemFields.CanDownload); - fieldsList.Remove(ItemFields.DisplayPreferencesId); - fieldsList.Remove(ItemFields.Etag); - dtoOptions.Fields = fieldsList.ToArray(); - dtoOptions.AddCurrentProgram = addCurrentProgram; - - var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user); - return new QueryResult<BaseItemDto>( - startIndex, - channelResult.TotalRecordCount, - returnArray); - } + /// <summary> + /// Gets available live tv channels. + /// </summary> + /// <param name="type">Optional. Filter by channel type.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param> + /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param> + /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="sortBy">Optional. Key to sort by.</param> + /// <param name="sortOrder">Optional. Sort order.</param> + /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param> + /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param> + /// <response code="200">Available live tv channels returned.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the resulting available live tv channels. + /// </returns> + [HttpGet("Channels")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult<QueryResult<BaseItemDto>> GetLiveTvChannels( + [FromQuery] ChannelType? type, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] int? limit, + [FromQuery] bool? isFavorite, + [FromQuery] bool? isLiked, + [FromQuery] bool? isDisliked, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] SortOrder? sortOrder, + [FromQuery] bool enableFavoriteSorting = false, + [FromQuery] bool addCurrentProgram = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - /// <summary> - /// Gets a live tv channel. - /// </summary> - /// <param name="channelId">Channel id.</param> - /// <param name="userId">Optional. Attach user data.</param> - /// <response code="200">Live tv channel returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns> - [HttpGet("Channels/{channelId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var item = channelId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(channelId); - - var dtoOptions = new DtoOptions() - .AddClientFields(User); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var channelResult = _liveTvManager.GetInternalChannels( + new LiveTvChannelQuery + { + ChannelType = type, + UserId = userId.Value, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + IsLiked = isLiked, + IsDisliked = isDisliked, + EnableFavoriteSorting = enableFavoriteSorting, + IsMovie = isMovie, + IsSeries = isSeries, + IsNews = isNews, + IsKids = isKids, + IsSports = isSports, + SortBy = sortBy, + SortOrder = sortOrder ?? SortOrder.Ascending, + AddCurrentProgram = addCurrentProgram + }, + dtoOptions, + CancellationToken.None); + + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var fieldsList = dtoOptions.Fields.ToList(); + fieldsList.Remove(ItemFields.CanDelete); + fieldsList.Remove(ItemFields.CanDownload); + fieldsList.Remove(ItemFields.DisplayPreferencesId); + fieldsList.Remove(ItemFields.Etag); + dtoOptions.Fields = fieldsList.ToArray(); + dtoOptions.AddCurrentProgram = addCurrentProgram; + + var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user); + return new QueryResult<BaseItemDto>( + startIndex, + channelResult.TotalRecordCount, + returnArray); + } - /// <summary> - /// Gets live tv recordings. - /// </summary> - /// <param name="channelId">Optional. Filter by channel id.</param> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="status">Optional. Filter by recording status.</param> - /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> - /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="isMovie">Optional. Filter for movies.</param> - /// <param name="isSeries">Optional. Filter for series.</param> - /// <param name="isKids">Optional. Filter for kids.</param> - /// <param name="isSports">Optional. Filter for sports.</param> - /// <param name="isNews">Optional. Filter for news.</param> - /// <param name="isLibraryItem">Optional. Filter for is library item.</param> - /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> - /// <response code="200">Live tv recordings returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> - [HttpGet("Recordings")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<QueryResult<BaseItemDto>> GetRecordings( - [FromQuery] string? channelId, - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] RecordingStatus? status, - [FromQuery] bool? isInProgress, - [FromQuery] string? seriesTimerId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] bool? isNews, - [FromQuery] bool? isLibraryItem, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - return _liveTvManager.GetRecordings( - new RecordingQuery - { - ChannelId = channelId, - UserId = userId ?? Guid.Empty, - StartIndex = startIndex, - Limit = limit, - Status = status, - SeriesTimerId = seriesTimerId, - IsInProgress = isInProgress, - EnableTotalRecordCount = enableTotalRecordCount, - IsMovie = isMovie, - IsNews = isNews, - IsSeries = isSeries, - IsKids = isKids, - IsSports = isSports, - IsLibraryItem = isLibraryItem, - Fields = fields, - ImageTypeLimit = imageTypeLimit, - EnableImages = enableImages - }, - dtoOptions); - } + /// <summary> + /// Gets a live tv channel. + /// </summary> + /// <param name="channelId">Channel id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Live tv channel returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns> + [HttpGet("Channels/{channelId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var item = channelId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(channelId); + + var dtoOptions = new DtoOptions() + .AddClientFields(User); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - /// <summary> - /// Gets live tv recording series. - /// </summary> - /// <param name="channelId">Optional. Filter by channel id.</param> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <param name="groupId">Optional. Filter by recording group.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="status">Optional. Filter by recording status.</param> - /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> - /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> - /// <response code="200">Live tv recordings returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> - [HttpGet("Recordings/Series")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")] - public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries( - [FromQuery] string? channelId, - [FromQuery] Guid? userId, - [FromQuery] string? groupId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] RecordingStatus? status, - [FromQuery] bool? isInProgress, - [FromQuery] string? seriesTimerId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery] bool enableTotalRecordCount = true) - { - return new QueryResult<BaseItemDto>(); - } + /// <summary> + /// Gets live tv recordings. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="status">Optional. Filter by recording status.</param> + /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> + /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isLibraryItem">Optional. Filter for is library item.</param> + /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> + /// <response code="200">Live tv recordings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> + [HttpGet("Recordings")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecordings( + [FromQuery] string? channelId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] RecordingStatus? status, + [FromQuery] bool? isInProgress, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool? isNews, + [FromQuery] bool? isLibraryItem, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - /// <summary> - /// Gets live tv recording groups. - /// </summary> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <response code="200">Recording groups returned.</response> - /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns> - [HttpGet("Recordings/Groups")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] - public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId) - { - return new QueryResult<BaseItemDto>(); - } + return await _liveTvManager.GetRecordingsAsync( + new RecordingQuery + { + ChannelId = channelId, + UserId = userId.Value, + StartIndex = startIndex, + Limit = limit, + Status = status, + SeriesTimerId = seriesTimerId, + IsInProgress = isInProgress, + EnableTotalRecordCount = enableTotalRecordCount, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + IsLibraryItem = isLibraryItem, + Fields = fields, + ImageTypeLimit = imageTypeLimit, + EnableImages = enableImages + }, + dtoOptions).ConfigureAwait(false); + } - /// <summary> - /// Gets recording folders. - /// </summary> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <response code="200">Recording folders returned.</response> - /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns> - [HttpGet("Recordings/Folders")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var folders = _liveTvManager.GetRecordingFolders(user); + /// <summary> + /// Gets live tv recording series. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <param name="groupId">Optional. Filter by recording group.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="status">Optional. Filter by recording status.</param> + /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> + /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> + /// <response code="200">Live tv recordings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> + [HttpGet("Recordings/Series")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + [Obsolete("This endpoint is obsolete.")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries( + [FromQuery] string? channelId, + [FromQuery] Guid? userId, + [FromQuery] string? groupId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] RecordingStatus? status, + [FromQuery] bool? isInProgress, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool enableTotalRecordCount = true) + { + return new QueryResult<BaseItemDto>(); + } - var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); + /// <summary> + /// Gets live tv recording groups. + /// </summary> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <response code="200">Recording groups returned.</response> + /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns> + [HttpGet("Recordings/Groups")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + [Obsolete("This endpoint is obsolete.")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId) + { + return new QueryResult<BaseItemDto>(); + } - return new QueryResult<BaseItemDto>(returnArray); - } + /// <summary> + /// Gets recording folders. + /// </summary> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <response code="200">Recording folders returned.</response> + /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns> + [HttpGet("Recordings/Folders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecordingFolders([FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var folders = await _liveTvManager.GetRecordingFoldersAsync(user).ConfigureAwait(false); - /// <summary> - /// Gets a live tv recording. - /// </summary> - /// <param name="recordingId">Recording id.</param> - /// <param name="userId">Optional. Attach user data.</param> - /// <response code="200">Recording returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns> - [HttpGet("Recordings/{recordingId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); + var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); - var dtoOptions = new DtoOptions() - .AddClientFields(User); + return new QueryResult<BaseItemDto>(returnArray); + } - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + /// <summary> + /// Gets a live tv recording. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Recording returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns> + [HttpGet("Recordings/{recordingId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); - /// <summary> - /// Resets a tv tuner. - /// </summary> - /// <param name="tunerId">Tuner id.</param> - /// <response code="204">Tuner reset.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Tuners/{tunerId}/Reset")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + var dtoOptions = new DtoOptions() + .AddClientFields(User); - /// <summary> - /// Gets a timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="200">Timer returned.</response> - /// <returns> - /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer. - /// </returns> - [HttpGet("Timers/{timerId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<TimerInfoDto>> GetTimer([FromRoute, Required] string timerId) - { - return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false); - } + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - /// <summary> - /// Gets the default values for a new timer. - /// </summary> - /// <param name="programId">Optional. To attach default values based on a program.</param> - /// <response code="200">Default values returned.</response> - /// <returns> - /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer. - /// </returns> - [HttpGet("Timers/Defaults")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId) - { - return string.IsNullOrEmpty(programId) - ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false) - : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Resets a tv tuner. + /// </summary> + /// <param name="tunerId">Tuner id.</param> + /// <response code="204">Tuner reset.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Tuners/{tunerId}/Reset")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.LiveTvManagement)] + public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId) + { + await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Gets the live tv timers. - /// </summary> - /// <param name="channelId">Optional. Filter by channel id.</param> - /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param> - /// <param name="isActive">Optional. Filter by timers that are active.</param> - /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param> - /// <returns> - /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers. - /// </returns> - [HttpGet("Timers")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers( - [FromQuery] string? channelId, - [FromQuery] string? seriesTimerId, - [FromQuery] bool? isActive, - [FromQuery] bool? isScheduled) - { - return await _liveTvManager.GetTimers( - new TimerQuery - { - ChannelId = channelId, - SeriesTimerId = seriesTimerId, - IsActive = isActive, - IsScheduled = isScheduled - }, - CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Gets a timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="200">Timer returned.</response> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer. + /// </returns> + [HttpGet("Timers/{timerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task<ActionResult<TimerInfoDto>> GetTimer([FromRoute, Required] string timerId) + { + return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Gets available live tv epgs. - /// </summary> - /// <param name="channelIds">The channels to return guide information for.</param> - /// <param name="userId">Optional. Filter by user id.</param> - /// <param name="minStartDate">Optional. The minimum premiere start date.</param> - /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> - /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> - /// <param name="maxStartDate">Optional. The maximum premiere start date.</param> - /// <param name="minEndDate">Optional. The minimum premiere end date.</param> - /// <param name="maxEndDate">Optional. The maximum premiere end date.</param> - /// <param name="isMovie">Optional. Filter for movies.</param> - /// <param name="isSeries">Optional. Filter for series.</param> - /// <param name="isNews">Optional. Filter for news.</param> - /// <param name="isKids">Optional. Filter for kids.</param> - /// <param name="isSports">Optional. Filter for sports.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="genres">The genres to return guide information for.</param> - /// <param name="genreIds">The genre ids to return guide information for.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="seriesTimerId">Optional. Filter by series timer id.</param> - /// <param name="librarySeriesId">Optional. Filter by library series id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableTotalRecordCount">Retrieve total record count.</param> - /// <response code="200">Live tv epgs returned.</response> - /// <returns> - /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. - /// </returns> - [HttpGet("Programs")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, - [FromQuery] Guid? userId, - [FromQuery] DateTime? minStartDate, - [FromQuery] bool? hasAired, - [FromQuery] bool? isAiring, - [FromQuery] DateTime? maxStartDate, - [FromQuery] DateTime? minEndDate, - [FromQuery] DateTime? maxEndDate, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] string? seriesTimerId, - [FromQuery] Guid? librarySeriesId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool enableTotalRecordCount = true) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + /// <summary> + /// Gets the default values for a new timer. + /// </summary> + /// <param name="programId">Optional. To attach default values based on a program.</param> + /// <response code="200">Default values returned.</response> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer. + /// </returns> + [HttpGet("Timers/Defaults")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId) + { + return string.IsNullOrEmpty(programId) + ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false) + : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false); + } - var query = new InternalItemsQuery(user) + /// <summary> + /// Gets the live tv timers. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param> + /// <param name="isActive">Optional. Filter by timers that are active.</param> + /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers. + /// </returns> + [HttpGet("Timers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers( + [FromQuery] string? channelId, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? isActive, + [FromQuery] bool? isScheduled) + { + return await _liveTvManager.GetTimers( + new TimerQuery { - ChannelIds = channelIds, - HasAired = hasAired, - IsAiring = isAiring, - EnableTotalRecordCount = enableTotalRecordCount, - MinStartDate = minStartDate, - MinEndDate = minEndDate, - MaxStartDate = maxStartDate, - MaxEndDate = maxEndDate, - StartIndex = startIndex, - Limit = limit, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - IsNews = isNews, - IsMovie = isMovie, - IsSeries = isSeries, - IsKids = isKids, - IsSports = isSports, + ChannelId = channelId, SeriesTimerId = seriesTimerId, - Genres = genres, - GenreIds = genreIds - }; - - if (librarySeriesId.HasValue && !librarySeriesId.Equals(default)) - { - query.IsSeries = true; - - if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) - { - query.Name = series.Name; - } - } + IsActive = isActive, + IsScheduled = isScheduled + }, + CancellationToken.None).ConfigureAwait(false); + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Gets available live tv epgs. + /// </summary> + /// <param name="channelIds">The channels to return guide information for.</param> + /// <param name="userId">Optional. Filter by user id.</param> + /// <param name="minStartDate">Optional. The minimum premiere start date.</param> + /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> + /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> + /// <param name="maxStartDate">Optional. The maximum premiere start date.</param> + /// <param name="minEndDate">Optional. The minimum premiere end date.</param> + /// <param name="maxEndDate">Optional. The maximum premiere end date.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="genres">The genres to return guide information for.</param> + /// <param name="genreIds">The genre ids to return guide information for.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="seriesTimerId">Optional. Filter by series timer id.</param> + /// <param name="librarySeriesId">Optional. Filter by library series id.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableTotalRecordCount">Retrieve total record count.</param> + /// <response code="200">Live tv epgs returned.</response> + /// <returns> + /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. + /// </returns> + [HttpGet("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, + [FromQuery] Guid? userId, + [FromQuery] DateTime? minStartDate, + [FromQuery] bool? hasAired, + [FromQuery] bool? isAiring, + [FromQuery] DateTime? maxStartDate, + [FromQuery] DateTime? minEndDate, + [FromQuery] DateTime? maxEndDate, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] string? seriesTimerId, + [FromQuery] Guid? librarySeriesId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Gets available live tv epgs. - /// </summary> - /// <param name="body">Request body.</param> - /// <response code="200">Live tv epgs returned.</response> - /// <returns> - /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. - /// </returns> - [HttpPost("Programs")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body) + var query = new InternalItemsQuery(user) { - var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId); + ChannelIds = channelIds, + HasAired = hasAired, + IsAiring = isAiring, + EnableTotalRecordCount = enableTotalRecordCount, + MinStartDate = minStartDate, + MinEndDate = minEndDate, + MaxStartDate = maxStartDate, + MaxEndDate = maxEndDate, + StartIndex = startIndex, + Limit = limit, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsNews = isNews, + IsMovie = isMovie, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + SeriesTimerId = seriesTimerId, + Genres = genres, + GenreIds = genreIds + }; + + if (librarySeriesId.HasValue && !librarySeriesId.Equals(default)) + { + query.IsSeries = true; - var query = new InternalItemsQuery(user) - { - ChannelIds = body.ChannelIds, - HasAired = body.HasAired, - IsAiring = body.IsAiring, - EnableTotalRecordCount = body.EnableTotalRecordCount, - MinStartDate = body.MinStartDate, - MinEndDate = body.MinEndDate, - MaxStartDate = body.MaxStartDate, - MaxEndDate = body.MaxEndDate, - StartIndex = body.StartIndex, - Limit = body.Limit, - OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder), - IsNews = body.IsNews, - IsMovie = body.IsMovie, - IsSeries = body.IsSeries, - IsKids = body.IsKids, - IsSports = body.IsSports, - SeriesTimerId = body.SeriesTimerId, - Genres = body.Genres, - GenreIds = body.GenreIds - }; - - if (!body.LibrarySeriesId.Equals(default)) + if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) { - query.IsSeries = true; - - if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) - { - query.Name = series.Name; - } + query.Name = series.Name; } - - var dtoOptions = new DtoOptions { Fields = body.Fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); - return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); } - /// <summary> - /// Gets recommended live tv epgs. - /// </summary> - /// <param name="userId">Optional. filter by user id.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> - /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> - /// <param name="isSeries">Optional. Filter for series.</param> - /// <param name="isMovie">Optional. Filter for movies.</param> - /// <param name="isNews">Optional. Filter for news.</param> - /// <param name="isKids">Optional. Filter for kids.</param> - /// <param name="isSports">Optional. Filter for sports.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="genreIds">The genres to return guide information for.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. include user data.</param> - /// <param name="enableTotalRecordCount">Retrieve total record count.</param> - /// <response code="200">Recommended epgs returned.</response> - /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns> - [HttpGet("Programs/Recommended")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecommendedPrograms( - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery] bool? isAiring, - [FromQuery] bool? hasAired, - [FromQuery] bool? isSeries, - [FromQuery] bool? isMovie, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery] bool enableTotalRecordCount = true) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var query = new InternalItemsQuery(user) - { - IsAiring = isAiring, - Limit = limit, - HasAired = hasAired, - IsSeries = isSeries, - IsMovie = isMovie, - IsKids = isKids, - IsNews = isNews, - IsSports = isSports, - EnableTotalRecordCount = enableTotalRecordCount, - GenreIds = genreIds - }; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Gets available live tv epgs. + /// </summary> + /// <param name="body">Request body.</param> + /// <response code="200">Live tv epgs returned.</response> + /// <returns> + /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. + /// </returns> + [HttpPost("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body) + { + var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId); - /// <summary> - /// Gets a live tv program. - /// </summary> - /// <param name="programId">Program id.</param> - /// <param name="userId">Optional. Attach user data.</param> - /// <response code="200">Program returned.</response> - /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns> - [HttpGet("Programs/{programId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<BaseItemDto>> GetProgram( - [FromRoute, Required] string programId, - [FromQuery] Guid? userId) + var query = new InternalItemsQuery(user) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); - } - - /// <summary> - /// Deletes a live tv recording. - /// </summary> - /// <param name="recordingId">Recording id.</param> - /// <response code="204">Recording deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpDelete("Recordings/{recordingId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteRecording([FromRoute, Required] Guid recordingId) + ChannelIds = body.ChannelIds, + HasAired = body.HasAired, + IsAiring = body.IsAiring, + EnableTotalRecordCount = body.EnableTotalRecordCount, + MinStartDate = body.MinStartDate, + MinEndDate = body.MinEndDate, + MaxStartDate = body.MaxStartDate, + MaxEndDate = body.MaxEndDate, + StartIndex = body.StartIndex, + Limit = body.Limit, + OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder), + IsNews = body.IsNews, + IsMovie = body.IsMovie, + IsSeries = body.IsSeries, + IsKids = body.IsKids, + IsSports = body.IsSports, + SeriesTimerId = body.SeriesTimerId, + Genres = body.Genres, + GenreIds = body.GenreIds + }; + + if (!body.LibrarySeriesId.Equals(default)) { - await AssertUserCanManageLiveTv().ConfigureAwait(false); + query.IsSeries = true; - var item = _libraryManager.GetItemById(recordingId); - if (item is null) + if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) { - return NotFound(); + query.Name = series.Name; } - - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false - }); - - return NoContent(); } - /// <summary> - /// Cancels a live tv timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="204">Timer deleted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Timers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); - return NoContent(); - } + var dtoOptions = new DtoOptions { Fields = body.Fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); + return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Updates a live tv timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <param name="timerInfo">New timer info.</param> - /// <response code="204">Timer updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Timers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Gets recommended live tv epgs. + /// </summary> + /// <param name="userId">Optional. filter by user id.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> + /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="genreIds">The genres to return guide information for.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. include user data.</param> + /// <param name="enableTotalRecordCount">Retrieve total record count.</param> + /// <response code="200">Recommended epgs returned.</response> + /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns> + [HttpGet("Programs/Recommended")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecommendedPrograms( + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] bool? isAiring, + [FromQuery] bool? hasAired, + [FromQuery] bool? isSeries, + [FromQuery] bool? isMovie, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Creates a live tv timer. - /// </summary> - /// <param name="timerInfo">New timer info.</param> - /// <response code="204">Timer created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Timers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo) + var query = new InternalItemsQuery(user) { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + IsAiring = isAiring, + Limit = limit, + HasAired = hasAired, + IsSeries = isSeries, + IsMovie = isMovie, + IsKids = isKids, + IsNews = isNews, + IsSports = isSports, + EnableTotalRecordCount = enableTotalRecordCount, + GenreIds = genreIds + }; + + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Gets a live tv series timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="200">Series timer returned.</response> - /// <response code="404">Series timer not found.</response> - /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns> - [HttpGet("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute, Required] string timerId) - { - var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false); - if (timer is null) - { - return NotFound(); - } + /// <summary> + /// Gets a live tv program. + /// </summary> + /// <param name="programId">Program id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Program returned.</response> + /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns> + [HttpGet("Programs/{programId}")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<BaseItemDto>> GetProgram( + [FromRoute, Required] string programId, + [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - return timer; - } + return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); + } - /// <summary> - /// Gets live tv series timers. - /// </summary> - /// <param name="sortBy">Optional. Sort by SortName or Priority.</param> - /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param> - /// <response code="200">Timers returned.</response> - /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns> - [HttpGet("SeriesTimers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder) + /// <summary> + /// Deletes a live tv recording. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <response code="204">Recording deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Recordings/{recordingId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteRecording([FromRoute, Required] Guid recordingId) + { + var item = _libraryManager.GetItemById(recordingId); + if (item is null) { - return await _liveTvManager.GetSeriesTimers( - new SeriesTimerQuery - { - SortOrder = sortOrder ?? SortOrder.Ascending, - SortBy = sortBy - }, - CancellationToken.None).ConfigureAwait(false); + return NotFound(); } - /// <summary> - /// Cancels a live tv series timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="204">Timer cancelled.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId) + _libraryManager.DeleteItem(item, new DeleteOptions { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); - return NoContent(); - } + DeleteFileLocation = false + }); - /// <summary> - /// Updates a live tv series timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <param name="seriesTimerInfo">New series timer info.</param> - /// <response code="204">Series timer updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); + } - /// <summary> - /// Creates a live tv series timer. - /// </summary> - /// <param name="seriesTimerInfo">New series timer info.</param> - /// <response code="204">Series timer info created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("SeriesTimers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Cancels a live tv timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="204">Timer deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Timers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId) + { + await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Updates a live tv timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <param name="timerInfo">New timer info.</param> + /// <response code="204">Timer updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Timers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) + { + await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Creates a live tv timer. + /// </summary> + /// <param name="timerInfo">New timer info.</param> + /// <response code="204">Timer created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Timers")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo) + { + await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Get recording group. - /// </summary> - /// <param name="groupId">Group id.</param> - /// <returns>A <see cref="NotFoundResult"/>.</returns> - [HttpGet("Recordings/Groups/{groupId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("This endpoint is obsolete.")] - public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId) + /// <summary> + /// Gets a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="200">Series timer returned.</response> + /// <response code="404">Series timer not found.</response> + /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns> + [HttpGet("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute, Required] string timerId) + { + var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false); + if (timer is null) { return NotFound(); } - /// <summary> - /// Get guid info. - /// </summary> - /// <response code="200">Guid info returned.</response> - /// <returns>An <see cref="OkResult"/> containing the guide info.</returns> - [HttpGet("GuideInfo")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<GuideInfo> GetGuideInfo() - { - return _liveTvManager.GetGuideInfo(); - } + return timer; + } - /// <summary> - /// Adds a tuner host. - /// </summary> - /// <param name="tunerHostInfo">New tuner host.</param> - /// <response code="200">Created tuner host returned.</response> - /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns> - [HttpPost("TunerHosts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) - { - return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); - } + /// <summary> + /// Gets live tv series timers. + /// </summary> + /// <param name="sortBy">Optional. Sort by SortName or Priority.</param> + /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param> + /// <response code="200">Timers returned.</response> + /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns> + [HttpGet("SeriesTimers")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder) + { + return await _liveTvManager.GetSeriesTimers( + new SeriesTimerQuery + { + SortOrder = sortOrder ?? SortOrder.Ascending, + SortBy = sortBy + }, + CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Deletes a tuner host. - /// </summary> - /// <param name="id">Tuner host id.</param> - /// <response code="204">Tuner host deleted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("TunerHosts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteTunerHost([FromQuery] string? id) - { - var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); - config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - _configurationManager.SaveConfiguration("livetv", config); - return NoContent(); - } + /// <summary> + /// Cancels a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="204">Timer cancelled.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId) + { + await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Gets default listings provider info. - /// </summary> - /// <response code="200">Default listings provider info returned.</response> - /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns> - [HttpGet("ListingProviders/Default")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<ListingsProviderInfo> GetDefaultListingProvider() - { - return new ListingsProviderInfo(); - } + /// <summary> + /// Updates a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <param name="seriesTimerInfo">New series timer info.</param> + /// <response code="204">Series timer updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Adds a listings provider. - /// </summary> - /// <param name="pw">Password.</param> - /// <param name="listingsProviderInfo">New listings info.</param> - /// <param name="validateListings">Validate listings.</param> - /// <param name="validateLogin">Validate login.</param> - /// <response code="200">Created listings provider returned.</response> - /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns> - [HttpPost("ListingProviders")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] - public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider( - [FromQuery] string? pw, - [FromBody] ListingsProviderInfo listingsProviderInfo, - [FromQuery] bool validateListings = false, - [FromQuery] bool validateLogin = false) - { - 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(); - } + /// <summary> + /// Creates a live tv series timer. + /// </summary> + /// <param name="seriesTimerInfo">New series timer info.</param> + /// <response code="204">Series timer info created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("SeriesTimers")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); - } + /// <summary> + /// Get recording group. + /// </summary> + /// <param name="groupId">Group id.</param> + /// <returns>A <see cref="NotFoundResult"/>.</returns> + [HttpGet("Recordings/Groups/{groupId}")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("This endpoint is obsolete.")] + public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId) + { + return NotFound(); + } - /// <summary> - /// Delete listing provider. - /// </summary> - /// <param name="id">Listing provider id.</param> - /// <response code="204">Listing provider deleted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("ListingProviders")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteListingProvider([FromQuery] string? id) - { - _liveTvManager.DeleteListingsProvider(id); - return NoContent(); - } + /// <summary> + /// Get guid info. + /// </summary> + /// <response code="200">Guid info returned.</response> + /// <returns>An <see cref="OkResult"/> containing the guide info.</returns> + [HttpGet("GuideInfo")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<GuideInfo> GetGuideInfo() + { + return _liveTvManager.GetGuideInfo(); + } + + /// <summary> + /// Adds a tuner host. + /// </summary> + /// <param name="tunerHostInfo">New tuner host.</param> + /// <response code="200">Created tuner host returned.</response> + /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns> + [HttpPost("TunerHosts")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) + { + return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); + } + + /// <summary> + /// Deletes a tuner host. + /// </summary> + /// <param name="id">Tuner host id.</param> + /// <response code="204">Tuner host deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("TunerHosts")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteTunerHost([FromQuery] string? id) + { + var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); + config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); + _configurationManager.SaveConfiguration("livetv", config); + return NoContent(); + } + + /// <summary> + /// Gets default listings provider info. + /// </summary> + /// <response code="200">Default listings provider info returned.</response> + /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns> + [HttpGet("ListingProviders/Default")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ListingsProviderInfo> GetDefaultListingProvider() + { + return new ListingsProviderInfo(); + } - /// <summary> - /// Gets available lineups. - /// </summary> - /// <param name="id">Provider id.</param> - /// <param name="type">Provider type.</param> - /// <param name="location">Location.</param> - /// <param name="country">Country.</param> - /// <response code="200">Available lineups returned.</response> - /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns> - [HttpGet("ListingProviders/Lineups")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups( - [FromQuery] string? id, - [FromQuery] string? type, - [FromQuery] string? location, - [FromQuery] string? country) + /// <summary> + /// Adds a listings provider. + /// </summary> + /// <param name="pw">Password.</param> + /// <param name="listingsProviderInfo">New listings info.</param> + /// <param name="validateListings">Validate listings.</param> + /// <param name="validateLogin">Validate login.</param> + /// <response code="200">Created listings provider returned.</response> + /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns> + [HttpPost("ListingProviders")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] + public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider( + [FromQuery] string? pw, + [FromBody] ListingsProviderInfo listingsProviderInfo, + [FromQuery] bool validateListings = false, + [FromQuery] bool validateLogin = false) + { + if (!string.IsNullOrEmpty(pw)) { - return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); + // 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(); } - /// <summary> - /// Gets available countries. - /// </summary> - /// <response code="200">Available countries returned.</response> - /// <returns>A <see cref="FileResult"/> containing the available countries.</returns> - [HttpGet("ListingProviders/SchedulesDirect/Countries")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Application.Json)] - public async Task<ActionResult> GetSchedulesDirectCountries() - { - var client = _httpClientFactory.CreateClient(NamedClient.Default); - // https://json.schedulesdirect.org/20141201/available/countries - // Can't dispose the response as it's required up the call chain. - var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries")) - .ConfigureAwait(false); + return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + } - return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); - } + /// <summary> + /// Delete listing provider. + /// </summary> + /// <param name="id">Listing provider id.</param> + /// <response code="204">Listing provider deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("ListingProviders")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteListingProvider([FromQuery] string? id) + { + _liveTvManager.DeleteListingsProvider(id); + return NoContent(); + } - /// <summary> - /// Get channel mapping options. - /// </summary> - /// <param name="providerId">Provider id.</param> - /// <response code="200">Channel mapping options returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns> - [HttpGet("ChannelMappingOptions")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId) - { - var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); + /// <summary> + /// Gets available lineups. + /// </summary> + /// <param name="id">Provider id.</param> + /// <param name="type">Provider type.</param> + /// <param name="location">Location.</param> + /// <param name="country">Country.</param> + /// <response code="200">Available lineups returned.</response> + /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns> + [HttpGet("ListingProviders/Lineups")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups( + [FromQuery] string? id, + [FromQuery] string? type, + [FromQuery] string? location, + [FromQuery] string? country) + { + return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); + } - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); + /// <summary> + /// Gets available countries. + /// </summary> + /// <response code="200">Available countries returned.</response> + /// <returns>A <see cref="FileResult"/> containing the available countries.</returns> + [HttpGet("ListingProviders/SchedulesDirect/Countries")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Json)] + public async Task<ActionResult> GetSchedulesDirectCountries() + { + var client = _httpClientFactory.CreateClient(NamedClient.Default); + // https://json.schedulesdirect.org/20141201/available/countries + // Can't dispose the response as it's required up the call chain. + var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries")) + .ConfigureAwait(false); - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; + return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); + } - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); + /// <summary> + /// Get channel mapping options. + /// </summary> + /// <param name="providerId">Provider id.</param> + /// <response code="200">Channel mapping options returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns> + [HttpGet("ChannelMappingOptions")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId) + { + var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); + var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - var mappings = listingsProviderInfo.ChannelMappings; + var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - return new ChannelMappingOptionsDto - { - TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), - ProviderChannels = providerChannels.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Id - }).ToList(), - Mappings = mappings, - ProviderName = listingsProviderName - }; - } + var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) + .ConfigureAwait(false); - /// <summary> - /// Set channel mappings. - /// </summary> - /// <param name="setChannelMappingDto">The set channel mapping dto.</param> - /// <response code="200">Created channel mapping returned.</response> - /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> - [HttpPost("ChannelMappings")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) - { - return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); - } + var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) + .ConfigureAwait(false); - /// <summary> - /// Get tuner host types. - /// </summary> - /// <response code="200">Tuner host types returned.</response> - /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns> - [HttpGet("TunerHosts/Types")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes() - { - return _liveTvManager.GetTunerHostTypes(); - } + var mappings = listingsProviderInfo.ChannelMappings; - /// <summary> - /// Discover tuners. - /// </summary> - /// <param name="newDevicesOnly">Only discover new tuners.</param> - /// <response code="200">Tuners returned.</response> - /// <returns>An <see cref="OkResult"/> containing the tuners.</returns> - [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] - [HttpGet("Tuners/Discover")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) + return new ChannelMappingOptionsDto { - return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); - } + TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), + ProviderChannels = providerChannels.Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Id + }).ToList(), + Mappings = mappings, + ProviderName = listingsProviderName + }; + } - /// <summary> - /// Gets a live tv recording stream. - /// </summary> - /// <param name="recordingId">Recording id.</param> - /// <response code="200">Recording stream returned.</response> - /// <response code="404">Recording not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the recording stream on success, - /// or a <see cref="NotFoundResult"/> if recording not found. - /// </returns> - [HttpGet("LiveRecordings/{recordingId}/stream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesVideoFile] - public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) - { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); + /// <summary> + /// Set channel mappings. + /// </summary> + /// <param name="setChannelMappingDto">The set channel mapping dto.</param> + /// <response code="200">Created channel mapping returned.</response> + /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> + [HttpPost("ChannelMappings")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) + { + return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); + } - if (string.IsNullOrWhiteSpace(path)) - { - return NotFound(); - } + /// <summary> + /// Get tuner host types. + /// </summary> + /// <response code="200">Tuner host types returned.</response> + /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns> + [HttpGet("TunerHosts/Types")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes() + { + return _liveTvManager.GetTunerHostTypes(); + } - var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); - return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); - } + /// <summary> + /// Discover tuners. + /// </summary> + /// <param name="newDevicesOnly">Only discover new tuners.</param> + /// <response code="200">Tuners returned.</response> + /// <returns>An <see cref="OkResult"/> containing the tuners.</returns> + [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] + [HttpGet("Tuners/Discover")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) + { + return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Gets a live tv channel stream. - /// </summary> - /// <param name="streamId">Stream id.</param> - /// <param name="container">Container type.</param> - /// <response code="200">Stream returned.</response> - /// <response code="404">Stream not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the channel stream on success, - /// or a <see cref="NotFoundResult"/> if stream not found. - /// </returns> - [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesVideoFile] - public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) - { - var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); - if (liveStreamInfo is null) - { - return NotFound(); - } + /// <summary> + /// Gets a live tv recording stream. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <response code="200">Recording stream returned.</response> + /// <response code="404">Recording not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the recording stream on success, + /// or a <see cref="NotFoundResult"/> if recording not found. + /// </returns> + [HttpGet("LiveRecordings/{recordingId}/stream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] + public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) + { + var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); - var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); - return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); + if (string.IsNullOrWhiteSpace(path)) + { + return NotFound(); } - private async Task AssertUserCanManageLiveTv() - { - var user = _userManager.GetUserById(User.GetUserId()); - var session = await _sessionManager.LogSessionActivity( - User.GetClient(), - User.GetVersion(), - User.GetDeviceId(), - User.GetDevice(), - HttpContext.GetNormalizedRemoteIp().ToString(), - user).ConfigureAwait(false); - - if (session.UserId.Equals(default)) - { - throw new SecurityException("Anonymous live tv management is not allowed."); - } + var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); + return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); + } - if (!user.HasPermission(PermissionKind.EnableLiveTvManagement)) - { - throw new SecurityException("The current user does not have permission to manage live tv."); - } + /// <summary> + /// Gets a live tv channel stream. + /// </summary> + /// <param name="streamId">Stream id.</param> + /// <param name="container">Container type.</param> + /// <response code="200">Stream returned.</response> + /// <response code="404">Stream not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the channel stream on success, + /// or a <see cref="NotFoundResult"/> if stream not found. + /// </returns> + [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] + public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) + { + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); + if (liveStreamInfo is null) + { + return NotFound(); } + + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); } } diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index 3d8b9e0cac..b9772a0693 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -6,71 +6,70 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Localization controller. +/// </summary> +[Authorize(Policy = Policies.FirstTimeSetupOrDefault)] +public class LocalizationController : BaseJellyfinApiController { + private readonly ILocalizationManager _localization; + /// <summary> - /// Localization controller. + /// Initializes a new instance of the <see cref="LocalizationController"/> class. /// </summary> - [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] - public class LocalizationController : BaseJellyfinApiController + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public LocalizationController(ILocalizationManager localization) { - private readonly ILocalizationManager _localization; - - /// <summary> - /// Initializes a new instance of the <see cref="LocalizationController"/> class. - /// </summary> - /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - public LocalizationController(ILocalizationManager localization) - { - _localization = localization; - } + _localization = localization; + } - /// <summary> - /// Gets known cultures. - /// </summary> - /// <response code="200">Known cultures returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns> - [HttpGet("Cultures")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<CultureDto>> GetCultures() - { - return Ok(_localization.GetCultures()); - } + /// <summary> + /// Gets known cultures. + /// </summary> + /// <response code="200">Known cultures returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns> + [HttpGet("Cultures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CultureDto>> GetCultures() + { + return Ok(_localization.GetCultures()); + } - /// <summary> - /// Gets known countries. - /// </summary> - /// <response code="200">Known countries returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns> - [HttpGet("Countries")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<CountryInfo>> GetCountries() - { - return Ok(_localization.GetCountries()); - } + /// <summary> + /// Gets known countries. + /// </summary> + /// <response code="200">Known countries returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns> + [HttpGet("Countries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CountryInfo>> GetCountries() + { + return Ok(_localization.GetCountries()); + } - /// <summary> - /// Gets known parental ratings. - /// </summary> - /// <response code="200">Known parental ratings returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns> - [HttpGet("ParentalRatings")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings() - { - return Ok(_localization.GetParentalRatings()); - } + /// <summary> + /// Gets known parental ratings. + /// </summary> + /// <response code="200">Known parental ratings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns> + [HttpGet("ParentalRatings")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings() + { + return Ok(_localization.GetParentalRatings()); + } - /// <summary> - /// Gets localization options. - /// </summary> - /// <response code="200">Localization options returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns> - [HttpGet("Options")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions() - { - return Ok(_localization.GetLocalizationOptions()); - } + /// <summary> + /// Gets localization options. + /// </summary> + /// <response code="200">Localization options returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns> + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions() + { + return Ok(_localization.GetLocalizationOptions()); } } diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 8115c35852..da24616ff3 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Net.Mime; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; @@ -19,295 +18,297 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The media info controller. +/// </summary> +[Route("")] +[Authorize] +public class MediaInfoController : BaseJellyfinApiController { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDeviceManager _deviceManager; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<MediaInfoController> _logger; + private readonly MediaInfoHelper _mediaInfoHelper; + /// <summary> - /// The media info controller. + /// Initializes a new instance of the <see cref="MediaInfoController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MediaInfoController : BaseJellyfinApiController + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param> + /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param> + public MediaInfoController( + IMediaSourceManager mediaSourceManager, + IDeviceManager deviceManager, + ILibraryManager libraryManager, + ILogger<MediaInfoController> logger, + MediaInfoHelper mediaInfoHelper) { - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IDeviceManager _deviceManager; - private readonly ILibraryManager _libraryManager; - private readonly ILogger<MediaInfoController> _logger; - private readonly MediaInfoHelper _mediaInfoHelper; + _mediaSourceManager = mediaSourceManager; + _deviceManager = deviceManager; + _libraryManager = libraryManager; + _logger = logger; + _mediaInfoHelper = mediaInfoHelper; + } - /// <summary> - /// Initializes a new instance of the <see cref="MediaInfoController"/> class. - /// </summary> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param> - /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param> - public MediaInfoController( - IMediaSourceManager mediaSourceManager, - IDeviceManager deviceManager, - ILibraryManager libraryManager, - ILogger<MediaInfoController> logger, - MediaInfoHelper mediaInfoHelper) - { - _mediaSourceManager = mediaSourceManager; - _deviceManager = deviceManager; - _libraryManager = libraryManager; - _logger = logger; - _mediaInfoHelper = mediaInfoHelper; - } + /// <summary> + /// Gets live playback media info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">The user id.</param> + /// <response code="200">Playback info returned.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> + [HttpGet("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) + { + return await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId) + .ConfigureAwait(false); + } - /// <summary> - /// Gets live playback media info for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">The user id.</param> - /// <response code="200">Playback info returned.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> - [HttpGet("Items/{itemId}/PlaybackInfo")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) - { - return await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId) - .ConfigureAwait(false); - } + /// <summary> + /// Gets live playback media info for an item. + /// </summary> + /// <remarks> + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// </remarks> + /// <param name="itemId">The item id.</param> + /// <param name="userId">The user id.</param> + /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="maxAudioChannels">The maximum number of audio channels.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="liveStreamId">The livestream id.</param> + /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param> + /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> + /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> + /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param> + /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param> + /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param> + /// <param name="playbackInfoDto">The playback info.</param> + /// <response code="200">Playback info returned.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> + [HttpPost("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( + [FromRoute, Required] Guid itemId, + [FromQuery, ParameterObsolete] Guid? userId, + [FromQuery, ParameterObsolete] int? maxStreamingBitrate, + [FromQuery, ParameterObsolete] long? startTimeTicks, + [FromQuery, ParameterObsolete] int? audioStreamIndex, + [FromQuery, ParameterObsolete] int? subtitleStreamIndex, + [FromQuery, ParameterObsolete] int? maxAudioChannels, + [FromQuery, ParameterObsolete] string? mediaSourceId, + [FromQuery, ParameterObsolete] string? liveStreamId, + [FromQuery, ParameterObsolete] bool? autoOpenLiveStream, + [FromQuery, ParameterObsolete] bool? enableDirectPlay, + [FromQuery, ParameterObsolete] bool? enableDirectStream, + [FromQuery, ParameterObsolete] bool? enableTranscoding, + [FromQuery, ParameterObsolete] bool? allowVideoStreamCopy, + [FromQuery, ParameterObsolete] bool? allowAudioStreamCopy, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto) + { + var profile = playbackInfoDto?.DeviceProfile; + _logger.LogDebug("GetPostedPlaybackInfo profile: {@Profile}", profile); - /// <summary> - /// Gets live playback media info for an item. - /// </summary> - /// <remarks> - /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. - /// Query parameters are obsolete. - /// </remarks> - /// <param name="itemId">The item id.</param> - /// <param name="userId">The user id.</param> - /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="maxAudioChannels">The maximum number of audio channels.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="liveStreamId">The livestream id.</param> - /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param> - /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> - /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> - /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param> - /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param> - /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param> - /// <param name="playbackInfoDto">The playback info.</param> - /// <response code="200">Playback info returned.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> - [HttpPost("Items/{itemId}/PlaybackInfo")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( - [FromRoute, Required] Guid itemId, - [FromQuery, ParameterObsolete] Guid? userId, - [FromQuery, ParameterObsolete] int? maxStreamingBitrate, - [FromQuery, ParameterObsolete] long? startTimeTicks, - [FromQuery, ParameterObsolete] int? audioStreamIndex, - [FromQuery, ParameterObsolete] int? subtitleStreamIndex, - [FromQuery, ParameterObsolete] int? maxAudioChannels, - [FromQuery, ParameterObsolete] string? mediaSourceId, - [FromQuery, ParameterObsolete] string? liveStreamId, - [FromQuery, ParameterObsolete] bool? autoOpenLiveStream, - [FromQuery, ParameterObsolete] bool? enableDirectPlay, - [FromQuery, ParameterObsolete] bool? enableDirectStream, - [FromQuery, ParameterObsolete] bool? enableTranscoding, - [FromQuery, ParameterObsolete] bool? allowVideoStreamCopy, - [FromQuery, ParameterObsolete] bool? allowAudioStreamCopy, - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto) + if (profile is null) { - var profile = playbackInfoDto?.DeviceProfile; - _logger.LogDebug("GetPostedPlaybackInfo profile: {@Profile}", profile); - - if (profile is null) + var caps = _deviceManager.GetCapabilities(User.GetDeviceId()); + if (caps is not null) { - var caps = _deviceManager.GetCapabilities(User.GetDeviceId()); - if (caps is not null) - { - profile = caps.DeviceProfile; - } + profile = caps.DeviceProfile; } + } - // Copy params from posted body - // TODO clean up when breaking API compatibility. - userId ??= playbackInfoDto?.UserId; - maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate; - startTimeTicks ??= playbackInfoDto?.StartTimeTicks; - audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex; - subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex; - maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels; - mediaSourceId ??= playbackInfoDto?.MediaSourceId; - liveStreamId ??= playbackInfoDto?.LiveStreamId; - autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false; - enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true; - enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true; - enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true; - allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true; - allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true; + // Copy params from posted body + // TODO clean up when breaking API compatibility. + userId ??= playbackInfoDto?.UserId; + userId = RequestHelpers.GetUserId(User, userId); + maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate; + startTimeTicks ??= playbackInfoDto?.StartTimeTicks; + audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex; + subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex; + maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels; + mediaSourceId ??= playbackInfoDto?.MediaSourceId; + liveStreamId ??= playbackInfoDto?.LiveStreamId; + autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false; + enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true; + enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true; + enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true; + allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true; + allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true; - var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, - mediaSourceId, - liveStreamId) - .ConfigureAwait(false); + var info = await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId, + mediaSourceId, + liveStreamId) + .ConfigureAwait(false); - if (info.ErrorCode is not null) - { - return info; - } + if (info.ErrorCode is not null) + { + return info; + } + + if (profile is not null) + { + // set device specific data + var item = _libraryManager.GetItemById(itemId); - if (profile is not null) + foreach (var mediaSource in info.MediaSources) { - // set device specific data - var item = _libraryManager.GetItemById(itemId); + _mediaInfoHelper.SetDeviceSpecificData( + item, + mediaSource, + profile, + User, + maxStreamingBitrate ?? profile.MaxStreamingBitrate, + startTimeTicks ?? 0, + mediaSourceId ?? string.Empty, + audioStreamIndex, + subtitleStreamIndex, + maxAudioChannels, + info.PlaySessionId!, + userId ?? Guid.Empty, + enableDirectPlay.Value, + enableDirectStream.Value, + enableTranscoding.Value, + allowVideoStreamCopy.Value, + allowAudioStreamCopy.Value, + Request.HttpContext.GetNormalizedRemoteIp()); + } - foreach (var mediaSource in info.MediaSources) - { - _mediaInfoHelper.SetDeviceSpecificData( - item, - mediaSource, - profile, - User, - maxStreamingBitrate ?? profile.MaxStreamingBitrate, - startTimeTicks ?? 0, - mediaSourceId ?? string.Empty, - audioStreamIndex, - subtitleStreamIndex, - maxAudioChannels, - info.PlaySessionId!, - userId ?? Guid.Empty, - enableDirectPlay.Value, - enableDirectStream.Value, - enableTranscoding.Value, - allowVideoStreamCopy.Value, - allowAudioStreamCopy.Value, - Request.HttpContext.GetNormalizedRemoteIp()); - } + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); - } + if (autoOpenLiveStream.Value) + { + var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal)); - if (autoOpenLiveStream.Value) + if (mediaSource is not null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId)) { - var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal)); - - if (mediaSource is not null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId)) - { - var openStreamResult = await _mediaInfoHelper.OpenMediaSource( - HttpContext, - new LiveStreamRequest - { - AudioStreamIndex = audioStreamIndex, - DeviceProfile = playbackInfoDto?.DeviceProfile, - EnableDirectPlay = enableDirectPlay.Value, - EnableDirectStream = enableDirectStream.Value, - ItemId = itemId, - MaxAudioChannels = maxAudioChannels, - MaxStreamingBitrate = maxStreamingBitrate, - PlaySessionId = info.PlaySessionId, - StartTimeTicks = startTimeTicks, - SubtitleStreamIndex = subtitleStreamIndex, - UserId = userId ?? Guid.Empty, - OpenToken = mediaSource.OpenToken - }).ConfigureAwait(false); + var openStreamResult = await _mediaInfoHelper.OpenMediaSource( + HttpContext, + new LiveStreamRequest + { + AudioStreamIndex = audioStreamIndex, + DeviceProfile = playbackInfoDto?.DeviceProfile, + EnableDirectPlay = enableDirectPlay.Value, + EnableDirectStream = enableDirectStream.Value, + ItemId = itemId, + MaxAudioChannels = maxAudioChannels, + MaxStreamingBitrate = maxStreamingBitrate, + PlaySessionId = info.PlaySessionId, + StartTimeTicks = startTimeTicks, + SubtitleStreamIndex = subtitleStreamIndex, + UserId = userId ?? Guid.Empty, + OpenToken = mediaSource.OpenToken + }).ConfigureAwait(false); - info.MediaSources = new[] { openStreamResult.MediaSource }; - } + info.MediaSources = new[] { openStreamResult.MediaSource }; } - - return info; } - /// <summary> - /// Opens a media source. - /// </summary> - /// <param name="openToken">The open token.</param> - /// <param name="userId">The user id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="maxAudioChannels">The maximum number of audio channels.</param> - /// <param name="itemId">The item id.</param> - /// <param name="openLiveStreamDto">The open live stream dto.</param> - /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> - /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> - /// <response code="200">Media source opened.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns> - [HttpPost("LiveStreams/Open")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream( - [FromQuery] string? openToken, - [FromQuery] Guid? userId, - [FromQuery] string? playSessionId, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] long? startTimeTicks, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] int? maxAudioChannels, - [FromQuery] Guid? itemId, - [FromBody] OpenLiveStreamDto? openLiveStreamDto, - [FromQuery] bool? enableDirectPlay, - [FromQuery] bool? enableDirectStream) + return info; + } + + /// <summary> + /// Opens a media source. + /// </summary> + /// <param name="openToken">The open token.</param> + /// <param name="userId">The user id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="maxAudioChannels">The maximum number of audio channels.</param> + /// <param name="itemId">The item id.</param> + /// <param name="openLiveStreamDto">The open live stream dto.</param> + /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> + /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> + /// <response code="200">Media source opened.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns> + [HttpPost("LiveStreams/Open")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream( + [FromQuery] string? openToken, + [FromQuery] Guid? userId, + [FromQuery] string? playSessionId, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] long? startTimeTicks, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? maxAudioChannels, + [FromQuery] Guid? itemId, + [FromBody] OpenLiveStreamDto? openLiveStreamDto, + [FromQuery] bool? enableDirectPlay, + [FromQuery] bool? enableDirectStream) + { + userId ??= openLiveStreamDto?.UserId; + userId = RequestHelpers.GetUserId(User, userId); + var request = new LiveStreamRequest { - var request = new LiveStreamRequest - { - OpenToken = openToken ?? openLiveStreamDto?.OpenToken, - UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty, - PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId, - MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate, - StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks, - AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex, - MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels, - ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty, - DeviceProfile = openLiveStreamDto?.DeviceProfile, - EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true, - EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true, - DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } - }; - return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false); - } + OpenToken = openToken ?? openLiveStreamDto?.OpenToken, + UserId = userId.Value, + PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId, + MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate, + StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks, + AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex, + MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels, + ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty, + DeviceProfile = openLiveStreamDto?.DeviceProfile, + EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true, + EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true, + DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } + }; + return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false); + } - /// <summary> - /// Closes a media source. - /// </summary> - /// <param name="liveStreamId">The livestream id.</param> - /// <response code="204">Livestream closed.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("LiveStreams/Close")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId) + /// <summary> + /// Closes a media source. + /// </summary> + /// <param name="liveStreamId">The livestream id.</param> + /// <response code="204">Livestream closed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("LiveStreams/Close")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId) + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Tests the network with a request with the size of the bitrate. + /// </summary> + /// <param name="size">The bitrate. Defaults to 102400.</param> + /// <response code="200">Test buffer returned.</response> + /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns> + [HttpGet("Playback/BitrateTest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Octet)] + public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400) + { + byte[] buffer = ArrayPool<byte>.Shared.Rent(size); + try { - await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); - return NoContent(); + Random.Shared.NextBytes(buffer); + return File(buffer, MediaTypeNames.Application.Octet); } - - /// <summary> - /// Tests the network with a request with the size of the bitrate. - /// </summary> - /// <param name="size">The bitrate. Defaults to 102400.</param> - /// <response code="200">Test buffer returned.</response> - /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns> - [HttpGet("Playback/BitrateTest")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Application.Octet)] - public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400) + finally { - byte[] buffer = ArrayPool<byte>.Shared.Rent(size); - try - { - Random.Shared.NextBytes(buffer); - return File(buffer, MediaTypeNames.Application.Octet); - } - finally - { - ArrayPool<byte>.Shared.Return(buffer); - } + ArrayPool<byte>.Shared.Return(buffer); } } } diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 3cf079362b..e1145481fa 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; @@ -18,122 +18,123 @@ using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Movies controller. +/// </summary> +[Authorize] +public class MoviesController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Movies controller. + /// Initializes a new instance of the <see cref="MoviesController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MoviesController : BaseJellyfinApiController + /// <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> + public MoviesController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + IServerConfigurationManager serverConfigurationManager) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <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> - public MoviesController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; - } - - /// <summary> - /// Gets movie recommendations. - /// </summary> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <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> - /// <response code="200">Movie recommendations returned.</response> - /// <returns>The list of movie recommendations.</returns> - [HttpGet("Recommendations")] - public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( - [FromQuery] Guid? userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User); - - var categories = new List<RecommendationDto>(); - - var parentIdGuid = parentId ?? Guid.Empty; + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _serverConfigurationManager = serverConfigurationManager; + } - 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 - }; + /// <summary> + /// Gets movie recommendations. + /// </summary> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <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> + /// <response code="200">Movie recommendations returned.</response> + /// <returns>The list of movie recommendations.</returns> + [HttpGet("Recommendations")] + public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( + [FromQuery] Guid? userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] int categoryLimit = 5, + [FromQuery] int itemLimit = 8) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User); - var recentlyPlayedMovies = _libraryManager.GetItemList(query); + var categories = new List<RecommendationDto>(); - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } + var parentIdGuid = parentId ?? Guid.Empty; - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + var query = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { - 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.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6)); - // 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(); + 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 categoryTypes = new List<IEnumerator<RecommendationDto>> + 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.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6)); + // 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, @@ -146,181 +147,180 @@ namespace Jellyfin.Api.Controllers hasActorFromRecentlyPlayed }; - while (categories.Count < categoryLimit) - { - var allEmpty = true; + while (categories.Count < categoryLimit) + { + var allEmpty = true; - foreach (var category in categoryTypes) + foreach (var category in categoryTypes) + { + if (category.MoveNext()) { - if (category.MoveNext()) - { - categories.Add(category.Current); - allEmpty = false; + categories.Add(category.Current); + allEmpty = false; - if (categories.Count >= categoryLimit) - { - break; - } + 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) + if (allEmpty) { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); + break; } + } - 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); + return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); + } - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } + 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); } - private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + foreach (var name in names) { - 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) + 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(); + .Take(itemLimit) + .ToList(); - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); + if (items.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } + 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); + } - private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + foreach (var name in names) { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } + 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(); - foreach (var item in baselineItems) + if (items.Count > 0) { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - SimilarTo = item, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - if (similar.Count > 0) + yield return new RecommendationDto { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = returnItems + }; } } + } - private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) + 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) { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director }) + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + foreach (var item in baselineItems) + { + var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) { - MaxListOrder = 3 + Limit = itemLimit, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + SimilarTo = item, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions }); - var itemIds = items.Select(i => i.Id).ToList(); + if (similar.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + yield return new RecommendationDto + { + BaselineItemName = item.Name, + CategoryId = item.Id, + RecommendationType = type, + Items = returnItems + }; + } } + } - private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items) + private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) + { + var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director }) { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty<string>())); + MaxListOrder = 3 + }); - var itemIds = items.Select(i => i.Id).ToList(); + var itemIds = items.Select(i => i.Id).ToList(); - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } + 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(); } } diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index f4fb5f44ab..435457af67 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -18,181 +17,187 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The music genres controller. +/// </summary> +[Authorize] +public class MusicGenresController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + /// <summary> - /// The music genres controller. + /// Initializes a new instance of the <see cref="MusicGenresController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MusicGenresController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + public MusicGenresController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - - /// <summary> - /// Initializes a new instance of the <see cref="MusicGenresController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> - public MusicGenresController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// <summary> - /// Gets all music genres from a given item, folder, or the entire library. - /// </summary> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> - /// <response code="200">Music genres returned.</response> - /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> - [HttpGet] - [Obsolete("Use GetGenres instead")] - public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isFavorite, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + /// <summary> + /// Gets all music genres from a given item, folder, or the entire library. + /// </summary> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> + /// <response code="200">Music genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> + [HttpGet] + [Obsolete("Use GetGenres instead")] + public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isFavorite, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + User? user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - var parentItem = _libraryManager.GetParentItem(parentId, userId); + var parentItem = _libraryManager.GetParentItem(parentId, userId); - var query = new InternalItemsQuery(user) + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; + + if (parentId.HasValue) + { + if (parentItem is Folder) { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) + query.AncestorIds = new[] { parentId.Value }; + } + else { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } + query.ItemIds = new[] { parentId.Value }; } + } - var result = _libraryManager.GetMusicGenres(query); + var result = _libraryManager.GetMusicGenres(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); - } + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } - /// <summary> - /// Gets a music genre, by name. - /// </summary> - /// <param name="genreName">The genre name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns> - [HttpGet("{genreName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); + /// <summary> + /// Gets a music genre, by name. + /// </summary> + /// <param name="genreName">The genre name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns> + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions().AddClientFields(User); - MusicGenre? item; + MusicGenre? item; - if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) - { - item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre); - } - else - { - item = _libraryManager.GetMusicGenre(genreName); - } + if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) + { + item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre); + } + else + { + item = _libraryManager.GetMusicGenre(genreName); + } - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); + if (item is null) + { + return NotFound(); + } - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + if (!userId.Value.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } - private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) - where T : BaseItem, new() + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery { - var result = libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 10f967dcde..0ba5e995fb 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -11,157 +11,156 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Package Controller. +/// </summary> +[Route("")] +[Authorize] +public class PackageController : BaseJellyfinApiController { + private readonly IInstallationManager _installationManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Package Controller. + /// Initializes a new instance of the <see cref="PackageController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PackageController : BaseJellyfinApiController + /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) { - private readonly IInstallationManager _installationManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="PackageController"/> class. - /// </summary> - /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) - { - _installationManager = installationManager; - _serverConfigurationManager = serverConfigurationManager; - } + _installationManager = installationManager; + _serverConfigurationManager = serverConfigurationManager; + } - /// <summary> - /// Gets a package by name or assembly GUID. - /// </summary> - /// <param name="name">The name of the package.</param> - /// <param name="assemblyGuid">The GUID of the associated assembly.</param> - /// <response code="200">Package retrieved.</response> - /// <returns>A <see cref="PackageInfo"/> containing package information.</returns> - [HttpGet("Packages/{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PackageInfo>> GetPackageInfo( - [FromRoute, Required] string name, - [FromQuery] Guid? assemblyGuid) + /// <summary> + /// Gets a package by name or assembly GUID. + /// </summary> + /// <param name="name">The name of the package.</param> + /// <param name="assemblyGuid">The GUID of the associated assembly.</param> + /// <response code="200">Package retrieved.</response> + /// <returns>A <see cref="PackageInfo"/> containing package information.</returns> + [HttpGet("Packages/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PackageInfo>> GetPackageInfo( + [FromRoute, Required] string name, + [FromQuery] Guid? assemblyGuid) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var result = _installationManager.FilterPackages( + packages, + name, + assemblyGuid ?? default) + .FirstOrDefault(); + + if (result is null) { - var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - var result = _installationManager.FilterPackages( - packages, - name, - assemblyGuid ?? default) - .FirstOrDefault(); - - if (result is null) - { - return NotFound(); - } - - return result; + return NotFound(); } - /// <summary> - /// Gets available packages. - /// </summary> - /// <response code="200">Available packages returned.</response> - /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> - [HttpGet("Packages")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<IEnumerable<PackageInfo>> GetPackages() - { - IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + return result; + } - return packages; - } + /// <summary> + /// Gets available packages. + /// </summary> + /// <response code="200">Available packages returned.</response> + /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> + [HttpGet("Packages")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<IEnumerable<PackageInfo>> GetPackages() + { + IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - /// <summary> - /// Installs a package. - /// </summary> - /// <param name="name">Package name.</param> - /// <param name="assemblyGuid">GUID of the associated assembly.</param> - /// <param name="version">Optional version. Defaults to latest version.</param> - /// <param name="repositoryUrl">Optional. Specify the repository to install from.</param> - /// <response code="204">Package found.</response> - /// <response code="404">Package not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> - [HttpPost("Packages/Installed/{name}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.RequiresElevation)] - public async Task<ActionResult> InstallPackage( - [FromRoute, Required] string name, - [FromQuery] Guid? assemblyGuid, - [FromQuery] string? version, - [FromQuery] string? repositoryUrl) - { - var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - if (!string.IsNullOrEmpty(repositoryUrl)) - { - packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))) - .ToList(); - } - - var package = _installationManager.GetCompatibleVersions( - packages, - name, - assemblyGuid ?? Guid.Empty, - specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) - .FirstOrDefault(); - - if (package is null) - { - return NotFound(); - } - - await _installationManager.InstallPackage(package).ConfigureAwait(false); - - return NoContent(); - } + return packages; + } - /// <summary> - /// Cancels a package installation. - /// </summary> - /// <param name="packageId">Installation Id.</param> - /// <response code="204">Installation cancelled.</response> - /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> - [HttpDelete("Packages/Installing/{packageId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CancelPackageInstallation( - [FromRoute, Required] Guid packageId) + /// <summary> + /// Installs a package. + /// </summary> + /// <param name="name">Package name.</param> + /// <param name="assemblyGuid">GUID of the associated assembly.</param> + /// <param name="version">Optional version. Defaults to latest version.</param> + /// <param name="repositoryUrl">Optional. Specify the repository to install from.</param> + /// <response code="204">Package found.</response> + /// <response code="404">Package not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> + [HttpPost("Packages/Installed/{name}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult> InstallPackage( + [FromRoute, Required] string name, + [FromQuery] Guid? assemblyGuid, + [FromQuery] string? version, + [FromQuery] string? repositoryUrl) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + if (!string.IsNullOrEmpty(repositoryUrl)) { - _installationManager.CancelInstallation(packageId); - return NoContent(); + packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))) + .ToList(); } - /// <summary> - /// Gets all package repositories. - /// </summary> - /// <response code="200">Package repositories returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns> - [HttpGet("Repositories")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories() - { - return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable()); - } + var package = _installationManager.GetCompatibleVersions( + packages, + name, + assemblyGuid ?? Guid.Empty, + specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) + .FirstOrDefault(); - /// <summary> - /// Sets the enabled and existing package repositories. - /// </summary> - /// <param name="repositoryInfos">The list of package repositories.</param> - /// <response code="204">Package repositories saved.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Repositories")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) + if (package is null) { - _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; - _serverConfigurationManager.SaveConfiguration(); - return NoContent(); + return NotFound(); } + + await _installationManager.InstallPackage(package).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Cancels a package installation. + /// </summary> + /// <param name="packageId">Installation Id.</param> + /// <response code="204">Installation cancelled.</response> + /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> + [HttpDelete("Packages/Installing/{packageId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CancelPackageInstallation( + [FromRoute, Required] Guid packageId) + { + _installationManager.CancelInstallation(packageId); + return NoContent(); + } + + /// <summary> + /// Gets all package repositories. + /// </summary> + /// <response code="200">Package repositories returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns> + [HttpGet("Repositories")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories() + { + return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable()); + } + + /// <summary> + /// Sets the enabled and existing package repositories. + /// </summary> + /// <param name="repositoryInfos">The list of package repositories.</param> + /// <response code="204">Package repositories saved.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Repositories")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) + { + _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 09f7281ecb..b4c6f490a0 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -1,8 +1,8 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; @@ -15,125 +15,126 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Persons controller. +/// </summary> +[Authorize] +public class PersonsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + /// <summary> - /// Persons controller. + /// Initializes a new instance of the <see cref="PersonsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PersonsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public PersonsController( + ILibraryManager libraryManager, + IDtoService dtoService, + IUserManager userManager) { - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _userManager = userManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="PersonsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public PersonsController( - ILibraryManager libraryManager, - IDtoService dtoService, - IUserManager userManager) - { - _libraryManager = libraryManager; - _dtoService = dtoService; - _userManager = userManager; - } + /// <summary> + /// Gets all persons. + /// </summary> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param> + /// <param name="userId">User id.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <response code="200">Persons returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetPersons( + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery] Guid? appearsInItemId, + [FromQuery] Guid? userId, + [FromQuery] bool? enableImages = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + User? user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Gets all persons. - /// </summary> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="searchTerm">The search term.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param> - /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param> - /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param> - /// <param name="userId">User id.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <response code="200">Persons returned.</response> - /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetPersons( - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery] Guid? appearsInItemId, - [FromQuery] Guid? userId, - [FromQuery] bool? enableImages = true) + var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); + var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery( + personTypes, + excludePersonTypes) { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + NameContains = searchTerm, + User = user, + IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, + AppearsInItemId = appearsInItemId ?? Guid.Empty, + Limit = limit ?? 0 + }); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + return new QueryResult<BaseItemDto>( + peopleItems + .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) + .ToArray()); + } - var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); - var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery( - personTypes, - excludePersonTypes) - { - NameContains = searchTerm, - User = user, - IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, - AppearsInItemId = appearsInItemId ?? Guid.Empty, - Limit = limit ?? 0 - }); + /// <summary> + /// Get person by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Person returned.</response> + /// <response code="404">Person not found.</response> + /// <returns>An <see cref="OkResult"/> containing the person on success, + /// or a <see cref="NotFoundResult"/> if person not found.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions() + .AddClientFields(User); - return new QueryResult<BaseItemDto>( - peopleItems - .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) - .ToArray()); + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get person by name. - /// </summary> - /// <param name="name">Person name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Person returned.</response> - /// <response code="404">Person not found.</response> - /// <returns>An <see cref="OkResult"/> containing the person on success, - /// or a <see cref="NotFoundResult"/> if person not found.</returns> - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId) + if (!userId.Value.Equals(default)) { - var dtoOptions = new DtoOptions() - .AddClientFields(User); - - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } - - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return _dtoService.GetBaseItemDto(item, dtoOptions); + var user = _userManager.GetUserById(userId.Value); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index e0c565da18..8d2a738d4a 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -4,8 +4,8 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.PlaylistDtos; using MediaBrowser.Controller.Dto; @@ -20,202 +20,204 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Playlists controller. +/// </summary> +[Authorize] +public class PlaylistsController : BaseJellyfinApiController { + private readonly IPlaylistManager _playlistManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + /// <summary> - /// Playlists controller. + /// Initializes a new instance of the <see cref="PlaylistsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PlaylistsController : BaseJellyfinApiController + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public PlaylistsController( + IDtoService dtoService, + IPlaylistManager playlistManager, + IUserManager userManager, + ILibraryManager libraryManager) { - private readonly IPlaylistManager _playlistManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - - /// <summary> - /// Initializes a new instance of the <see cref="PlaylistsController"/> class. - /// </summary> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public PlaylistsController( - IDtoService dtoService, - IPlaylistManager playlistManager, - IUserManager userManager, - ILibraryManager libraryManager) - { - _dtoService = dtoService; - _playlistManager = playlistManager; - _userManager = userManager; - _libraryManager = libraryManager; - } - - /// <summary> - /// Creates a new playlist. - /// </summary> - /// <remarks> - /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. - /// Query parameters are obsolete. - /// </remarks> - /// <param name="name">The playlist name.</param> - /// <param name="ids">The item ids.</param> - /// <param name="userId">The user id.</param> - /// <param name="mediaType">The media type.</param> - /// <param name="createPlaylistRequest">The create playlist payload.</param> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. - /// The task result contains an <see cref="OkResult"/> indicating success. - /// </returns> - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( - [FromQuery, ParameterObsolete] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids, - [FromQuery, ParameterObsolete] Guid? userId, - [FromQuery, ParameterObsolete] string? mediaType, - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) - { - if (ids.Count == 0) - { - ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>(); - } - - var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest - { - Name = name ?? createPlaylistRequest?.Name, - ItemIdList = ids, - UserId = userId ?? createPlaylistRequest?.UserId ?? default, - MediaType = mediaType ?? createPlaylistRequest?.MediaType - }).ConfigureAwait(false); - - return result; - } + _dtoService = dtoService; + _playlistManager = playlistManager; + _userManager = userManager; + _libraryManager = libraryManager; + } - /// <summary> - /// Adds items to a playlist. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="ids">Item id, comma delimited.</param> - /// <param name="userId">The userId.</param> - /// <response code="204">Items added to playlist.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("{playlistId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddToPlaylist( - [FromRoute, Required] Guid playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery] Guid? userId) + /// <summary> + /// Creates a new playlist. + /// </summary> + /// <remarks> + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// </remarks> + /// <param name="name">The playlist name.</param> + /// <param name="ids">The item ids.</param> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media type.</param> + /// <param name="createPlaylistRequest">The create playlist payload.</param> + /// <response code="200">Playlist created.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. + /// The task result contains an <see cref="OkResult"/> indicating success. + /// </returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( + [FromQuery, ParameterObsolete] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids, + [FromQuery, ParameterObsolete] Guid? userId, + [FromQuery, ParameterObsolete] string? mediaType, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) + { + if (ids.Count == 0) { - await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); - return NoContent(); + ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>(); } - /// <summary> - /// Moves a playlist item. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="newIndex">The new index.</param> - /// <response code="204">Item moved to new index.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> MoveItem( - [FromRoute, Required] string playlistId, - [FromRoute, Required] string itemId, - [FromRoute, Required] int newIndex) + userId ??= createPlaylistRequest?.UserId ?? default; + userId = RequestHelpers.GetUserId(User, userId); + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { - await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); - return NoContent(); - } + Name = name ?? createPlaylistRequest?.Name, + ItemIdList = ids, + UserId = userId.Value, + MediaType = mediaType ?? createPlaylistRequest?.MediaType + }).ConfigureAwait(false); - /// <summary> - /// Removes items from a playlist. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="entryIds">The item ids, comma delimited.</param> - /// <response code="204">Items removed.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpDelete("{playlistId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromPlaylist( - [FromRoute, Required] string playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) - { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); - return NoContent(); - } + return result; + } - /// <summary> - /// Gets the original items of a playlist. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="userId">User id.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</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> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Original playlist returned.</response> - /// <response code="404">Playlist not found.</response> - /// <returns>The original playlist items.</returns> - [HttpGet("{playlistId}/Items")] - public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( - [FromRoute, Required] Guid playlistId, - [FromQuery, Required] Guid userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var playlist = (Playlist)_libraryManager.GetItemById(playlistId); - if (playlist is null) - { - return NotFound(); - } + /// <summary> + /// Adds items to a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="ids">Item id, comma delimited.</param> + /// <param name="userId">The userId.</param> + /// <response code="204">Items added to playlist.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddToPlaylist( + [FromRoute, Required] Guid playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); + return NoContent(); + } - var user = userId.Equals(default) - ? null - : _userManager.GetUserById(userId); + /// <summary> + /// Moves a playlist item. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="newIndex">The new index.</param> + /// <response code="204">Item moved to new index.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> MoveItem( + [FromRoute, Required] string playlistId, + [FromRoute, Required] string itemId, + [FromRoute, Required] int newIndex) + { + await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); + return NoContent(); + } - var items = playlist.GetManageableItems().ToArray(); + /// <summary> + /// Removes items from a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="entryIds">The item ids, comma delimited.</param> + /// <response code="204">Items removed.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpDelete("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveFromPlaylist( + [FromRoute, Required] string playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) + { + await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); + return NoContent(); + } - var count = items.Length; + /// <summary> + /// Gets the original items of a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="userId">User id.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</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> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Original playlist returned.</response> + /// <response code="404">Playlist not found.</response> + /// <returns>The original playlist items.</returns> + [HttpGet("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( + [FromRoute, Required] Guid playlistId, + [FromQuery, Required] Guid userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var playlist = (Playlist)_libraryManager.GetItemById(playlistId); + if (playlist is null) + { + return NotFound(); + } - if (startIndex.HasValue) - { - items = items.Skip(startIndex.Value).ToArray(); - } + var user = userId.Equals(default) + ? null + : _userManager.GetUserById(userId); - if (limit.HasValue) - { - items = items.Take(limit.Value).ToArray(); - } + var items = playlist.GetManageableItems().ToArray(); + var count = items.Length; + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value).ToArray(); + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + if (limit.HasValue) + { + items = items.Take(limit.Value).ToArray(); + } - var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - for (int index = 0; index < dtos.Count; index++) - { - dtos[index].PlaylistItemId = items[index].Item1.Id; - } + var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); + for (int index = 0; index < dtos.Count; index++) + { + dtos[index].PlaylistItemId = items[index].Item1.Id; + } - var result = new QueryResult<BaseItemDto>( - startIndex, - count, - dtos); + var result = new QueryResult<BaseItemDto>( + startIndex, + count, + dtos); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 58f9b7d356..8ad553bcb8 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -2,11 +2,11 @@ using System; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; @@ -16,350 +16,385 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Playstate controller. +/// </summary> +[Route("")] +[Authorize] +public class PlaystateController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataRepository; + private readonly ILibraryManager _libraryManager; + private readonly ISessionManager _sessionManager; + private readonly ILogger<PlaystateController> _logger; + private readonly TranscodingJobHelper _transcodingJobHelper; + /// <summary> - /// Playstate controller. + /// Initializes a new instance of the <see cref="PlaystateController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PlaystateController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param> + public PlaystateController( + IUserManager userManager, + IUserDataManager userDataRepository, + ILibraryManager libraryManager, + ISessionManager sessionManager, + ILoggerFactory loggerFactory, + TranscodingJobHelper transcodingJobHelper) { - private readonly IUserManager _userManager; - private readonly IUserDataManager _userDataRepository; - private readonly ILibraryManager _libraryManager; - private readonly ISessionManager _sessionManager; - private readonly ILogger<PlaystateController> _logger; - private readonly TranscodingJobHelper _transcodingJobHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="PlaystateController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> - /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param> - public PlaystateController( - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - ISessionManager sessionManager, - ILoggerFactory loggerFactory, - TranscodingJobHelper transcodingJobHelper) - { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _sessionManager = sessionManager; - _logger = loggerFactory.CreateLogger<PlaystateController>(); + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _sessionManager = sessionManager; + _logger = loggerFactory.CreateLogger<PlaystateController>(); - _transcodingJobHelper = transcodingJobHelper; - } + _transcodingJobHelper = transcodingJobHelper; + } - /// <summary> - /// Marks an item as played for user. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="datePlayed">Optional. The date the item was played.</param> - /// <response code="200">Item marked as played.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpPost("Users/{userId}/PlayedItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) + /// <summary> + /// Marks an item as played for user. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="datePlayed">Optional. The date the item was played.</param> + /// <response code="200">Item marked as played.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns> + [HttpPost("Users/{userId}/PlayedItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) + { + var user = _userManager.GetUserById(userId); + if (user is null) { - var user = _userManager.GetUserById(userId); - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var dto = UpdatePlayedStatus(user, itemId, true, datePlayed); - foreach (var additionalUserInfo in session.AdditionalUsers) - { - var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); - UpdatePlayedStatus(additionalUser, itemId, true, datePlayed); - } + return NotFound(); + } + + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - return dto; + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Marks an item as unplayed for user. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item marked as unplayed.</response> - /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + var dto = UpdatePlayedStatus(user, item, true, datePlayed); + foreach (var additionalUserInfo in session.AdditionalUsers) { - var user = _userManager.GetUserById(userId); - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var dto = UpdatePlayedStatus(user, itemId, false, null); - foreach (var additionalUserInfo in session.AdditionalUsers) + var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); + if (additionalUser is null) { - var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); - UpdatePlayedStatus(additionalUser, itemId, false, null); + return NotFound(); } - return dto; + UpdatePlayedStatus(additionalUser, item, true, datePlayed); } - /// <summary> - /// Reports playback has started within a session. - /// </summary> - /// <param name="playbackStartInfo">The playback start info.</param> - /// <response code="204">Playback start recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo) - { - playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); - playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); - return NoContent(); - } + return dto; + } - /// <summary> - /// Reports playback progress within a session. - /// </summary> - /// <param name="playbackProgressInfo">The playback progress info.</param> - /// <response code="204">Playback progress recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing/Progress")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo) + /// <summary> + /// Marks an item as unplayed for user. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item marked as unplayed.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns> + [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) { - playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); - playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); - return NoContent(); + return NotFound(); } - /// <summary> - /// Pings a playback session. - /// </summary> - /// <param name="playSessionId">Playback session id.</param> - /// <response code="204">Playback session pinged.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing/Ping")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var item = _libraryManager.GetItemById(itemId); + + if (item is null) { - _transcodingJobHelper.PingTranscodingJob(playSessionId, null); - return NoContent(); + return NotFound(); } - /// <summary> - /// Reports playback has stopped within a session. - /// </summary> - /// <param name="playbackStopInfo">The playback stop info.</param> - /// <response code="204">Playback stop recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing/Stopped")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo) + var dto = UpdatePlayedStatus(user, item, false, null); + foreach (var additionalUserInfo in session.AdditionalUsers) { - _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); - if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) + var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); + if (additionalUser is null) { - await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + return NotFound(); } - playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); - return NoContent(); + UpdatePlayedStatus(additionalUser, item, false, null); } - /// <summary> - /// Reports that a user has begun playing an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="mediaSourceId">The id of the MediaSource.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="playMethod">The play method.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="canSeek">Indicates if the client can seek.</param> - /// <response code="204">Play start recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/PlayingItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task<ActionResult> OnPlaybackStart( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery] string? mediaSourceId, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] PlayMethod? playMethod, - [FromQuery] string? liveStreamId, - [FromQuery] string? playSessionId, - [FromQuery] bool canSeek = false) + return dto; + } + + /// <summary> + /// Reports playback has started within a session. + /// </summary> + /// <param name="playbackStartInfo">The playback start info.</param> + /// <response code="204">Playback start recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo) + { + playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); + playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Reports playback progress within a session. + /// </summary> + /// <param name="playbackProgressInfo">The playback progress info.</param> + /// <response code="204">Playback progress recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo) + { + playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); + playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Pings a playback session. + /// </summary> + /// <param name="playSessionId">Playback session id.</param> + /// <response code="204">Playback session pinged.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) + { + _transcodingJobHelper.PingTranscodingJob(playSessionId, null); + return NoContent(); + } + + /// <summary> + /// Reports playback has stopped within a session. + /// </summary> + /// <param name="playbackStopInfo">The playback stop info.</param> + /// <response code="204">Playback stop recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Stopped")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo) + { + _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); + if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - var playbackStartInfo = new PlaybackStartInfo - { - CanSeek = canSeek, - ItemId = itemId, - MediaSourceId = mediaSourceId, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - PlayMethod = playMethod ?? PlayMethod.Transcode, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId - }; - - playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); - playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); - return NoContent(); + await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); } - /// <summary> - /// Reports a user's playback progress. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="mediaSourceId">The id of the MediaSource.</param> - /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="volumeLevel">Scale of 0-100.</param> - /// <param name="playMethod">The play method.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="repeatMode">The repeat mode.</param> - /// <param name="isPaused">Indicates if the player is paused.</param> - /// <param name="isMuted">Indicates if the player is muted.</param> - /// <response code="204">Play progress recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task<ActionResult> OnPlaybackProgress( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery] string? mediaSourceId, - [FromQuery] long? positionTicks, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] int? volumeLevel, - [FromQuery] PlayMethod? playMethod, - [FromQuery] string? liveStreamId, - [FromQuery] string? playSessionId, - [FromQuery] RepeatMode? repeatMode, - [FromQuery] bool isPaused = false, - [FromQuery] bool isMuted = false) + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Reports that a user has begun playing an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="playMethod">The play method.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="canSeek">Indicates if the client can seek.</param> + /// <response code="204">Play start recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackStart( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] PlayMethod? playMethod, + [FromQuery] string? liveStreamId, + [FromQuery] string? playSessionId, + [FromQuery] bool canSeek = false) + { + var playbackStartInfo = new PlaybackStartInfo { - var playbackProgressInfo = new PlaybackProgressInfo - { - ItemId = itemId, - PositionTicks = positionTicks, - IsMuted = isMuted, - IsPaused = isPaused, - MediaSourceId = mediaSourceId, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - VolumeLevel = volumeLevel, - PlayMethod = playMethod ?? PlayMethod.Transcode, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - RepeatMode = repeatMode ?? RepeatMode.RepeatNone - }; - - playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); - playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); - return NoContent(); - } + CanSeek = canSeek, + ItemId = itemId, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + PlayMethod = playMethod ?? PlayMethod.Transcode, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId + }; + + playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); + playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Reports that a user has stopped playing an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="mediaSourceId">The id of the MediaSource.</param> - /// <param name="nextMediaType">The next media type that will play.</param> - /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <response code="204">Playback stop recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Users/{userId}/PlayingItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task<ActionResult> OnPlaybackStopped( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery] string? mediaSourceId, - [FromQuery] string? nextMediaType, - [FromQuery] long? positionTicks, - [FromQuery] string? liveStreamId, - [FromQuery] string? playSessionId) + /// <summary> + /// Reports a user's playback progress. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="volumeLevel">Scale of 0-100.</param> + /// <param name="playMethod">The play method.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="repeatMode">The repeat mode.</param> + /// <param name="isPaused">Indicates if the player is paused.</param> + /// <param name="isMuted">Indicates if the player is muted.</param> + /// <response code="204">Play progress recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackProgress( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] long? positionTicks, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? volumeLevel, + [FromQuery] PlayMethod? playMethod, + [FromQuery] string? liveStreamId, + [FromQuery] string? playSessionId, + [FromQuery] RepeatMode? repeatMode, + [FromQuery] bool isPaused = false, + [FromQuery] bool isMuted = false) + { + var playbackProgressInfo = new PlaybackProgressInfo { - var playbackStopInfo = new PlaybackStopInfo - { - ItemId = itemId, - PositionTicks = positionTicks, - MediaSourceId = mediaSourceId, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - NextMediaType = nextMediaType - }; - - _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); - if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) - { - await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); - } + ItemId = itemId, + PositionTicks = positionTicks, + IsMuted = isMuted, + IsPaused = isPaused, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + VolumeLevel = volumeLevel, + PlayMethod = playMethod ?? PlayMethod.Transcode, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + RepeatMode = repeatMode ?? RepeatMode.RepeatNone + }; - playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); - return NoContent(); - } + playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); + playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Reports that a user has stopped playing an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="nextMediaType">The next media type that will play.</param> + /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <response code="204">Playback stop recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackStopped( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] string? nextMediaType, + [FromQuery] long? positionTicks, + [FromQuery] string? liveStreamId, + [FromQuery] string? playSessionId) + { + var playbackStopInfo = new PlaybackStopInfo + { + ItemId = itemId, + PositionTicks = positionTicks, + MediaSourceId = mediaSourceId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + NextMediaType = nextMediaType + }; - /// <summary> - /// Updates the played status. - /// </summary> - /// <param name="user">The user.</param> - /// <param name="itemId">The item id.</param> - /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> - /// <param name="datePlayed">The date played.</param> - /// <returns>Task.</returns> - private UserItemDataDto UpdatePlayedStatus(User user, Guid itemId, bool wasPlayed, DateTime? datePlayed) + _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); + if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - var item = _libraryManager.GetItemById(itemId); + await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + } - if (wasPlayed) - { - item.MarkPlayed(user, datePlayed, true); - } - else - { - item.MarkUnplayed(user); - } + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } - return _userDataRepository.GetUserDataDto(item, user); + /// <summary> + /// Updates the played status. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> + /// <param name="datePlayed">The date played.</param> + /// <returns>Task.</returns> + private UserItemDataDto UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed) + { + if (wasPlayed) + { + item.MarkPlayed(user, datePlayed, true); + } + else + { + item.MarkUnplayed(user); } - private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId) + return _userDataRepository.GetUserDataDto(item, user); + } + + private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId) + { + if (method == PlayMethod.Transcode) { - if (method == PlayMethod.Transcode) + var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); + if (job is null) { - var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); - if (job is null) - { - return PlayMethod.DirectPlay; - } + return PlayMethod.DirectPlay; } - - return method; } + + return method; } } diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index b8a09990a5..72ad14a281 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; @@ -17,250 +16,249 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Plugins controller. +/// </summary> +[Authorize] +public class PluginsController : BaseJellyfinApiController { + private readonly IInstallationManager _installationManager; + private readonly IPluginManager _pluginManager; + private readonly JsonSerializerOptions _serializerOptions; + /// <summary> - /// Plugins controller. + /// Initializes a new instance of the <see cref="PluginsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PluginsController : BaseJellyfinApiController + /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> + public PluginsController( + IInstallationManager installationManager, + IPluginManager pluginManager) { - private readonly IInstallationManager _installationManager; - private readonly IPluginManager _pluginManager; - private readonly JsonSerializerOptions _serializerOptions; + _installationManager = installationManager; + _pluginManager = pluginManager; + _serializerOptions = JsonDefaults.Options; + } - /// <summary> - /// Initializes a new instance of the <see cref="PluginsController"/> class. - /// </summary> - /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> - /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> - public PluginsController( - IInstallationManager installationManager, - IPluginManager pluginManager) + /// <summary> + /// Gets a list of currently installed plugins. + /// </summary> + /// <response code="200">Installed plugins returned.</response> + /// <returns>List of currently installed plugins.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<PluginInfo>> GetPlugins() + { + return Ok(_pluginManager.Plugins + .OrderBy(p => p.Name) + .Select(p => p.GetPluginInfo())); + } + + /// <summary> + /// Enables a disabled plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin enabled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/{version}/Enable")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) { - _installationManager = installationManager; - _pluginManager = pluginManager; - _serializerOptions = JsonDefaults.Options; + return NotFound(); } - /// <summary> - /// Gets a list of currently installed plugins. - /// </summary> - /// <response code="200">Installed plugins returned.</response> - /// <returns>List of currently installed plugins.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<PluginInfo>> GetPlugins() + _pluginManager.EnablePlugin(plugin); + return NoContent(); + } + + /// <summary> + /// Disable a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin disabled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/{version}/Disable")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) { - return Ok(_pluginManager.Plugins - .OrderBy(p => p.Name) - .Select(p => p.GetPluginInfo())); + return NotFound(); } - /// <summary> - /// Enables a disabled plugin. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="204">Plugin enabled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/{version}/Enable")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) - { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } + _pluginManager.DisablePlugin(plugin); + return NoContent(); + } - _pluginManager.EnablePlugin(plugin); - return NoContent(); + /// <summary> + /// Uninstalls a plugin by version. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin uninstalled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpDelete("{pluginId}/{version}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) + { + return NotFound(); } - /// <summary> - /// Disable a plugin. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="204">Plugin disabled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/{version}/Disable")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) - { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } + _installationManager.UninstallPlugin(plugin); + return NoContent(); + } - _pluginManager.DisablePlugin(plugin); - return NoContent(); - } + /// <summary> + /// Uninstalls a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin uninstalled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpDelete("{pluginId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use the UninstallPluginByVersion API.")] + public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId) + { + // If no version is given, return the current instance. + var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList(); - /// <summary> - /// Uninstalls a plugin by version. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="204">Plugin uninstalled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpDelete("{pluginId}/{version}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) - { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } + // Select the un-instanced one first. + var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.MinBy(p => p.Manifest.Status); + if (plugin is not null) + { _installationManager.UninstallPlugin(plugin); return NoContent(); } - /// <summary> - /// Uninstalls a plugin. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <response code="204">Plugin uninstalled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpDelete("{pluginId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("Please use the UninstallPluginByVersion API.")] - public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId) - { - // If no version is given, return the current instance. - var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList(); - - // Select the un-instanced one first. - var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault(); - - if (plugin is not null) - { - _installationManager.UninstallPlugin(plugin); - return NoContent(); - } + return NotFound(); + } - return NotFound(); + /// <summary> + /// Gets plugin configuration. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="200">Plugin configuration returned.</response> + /// <response code="404">Plugin not found or plugin configuration not found.</response> + /// <returns>Plugin configuration.</returns> + [HttpGet("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is IHasPluginConfiguration configPlugin) + { + return configPlugin.Configuration; } - /// <summary> - /// Gets plugin configuration. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <response code="200">Plugin configuration returned.</response> - /// <response code="404">Plugin not found or plugin configuration not found.</response> - /// <returns>Plugin configuration.</returns> - [HttpGet("{pluginId}/Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); - if (plugin?.Instance is IHasPluginConfiguration configPlugin) - { - return configPlugin.Configuration; - } + return NotFound(); + } + /// <summary> + /// Updates plugin configuration. + /// </summary> + /// <remarks> + /// Accepts plugin configuration as JSON body. + /// </remarks> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin configuration updated.</response> + /// <response code="404">Plugin not found or plugin does not have configuration.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is not IHasPluginConfiguration configPlugin) + { return NotFound(); } - /// <summary> - /// Updates plugin configuration. - /// </summary> - /// <remarks> - /// Accepts plugin configuration as JSON body. - /// </remarks> - /// <param name="pluginId">Plugin id.</param> - /// <response code="204">Plugin configuration updated.</response> - /// <response code="404">Plugin not found or plugin does not have configuration.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/Configuration")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); - if (plugin?.Instance is not IHasPluginConfiguration configPlugin) - { - return NotFound(); - } + var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) + .ConfigureAwait(false); - var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) - .ConfigureAwait(false); + if (configuration is not null) + { + configPlugin.UpdateConfiguration(configuration); + } - if (configuration is not null) - { - configPlugin.UpdateConfiguration(configuration); - } + return NoContent(); + } - return NoContent(); + /// <summary> + /// Gets a plugin's image. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="200">Plugin image returned.</response> + /// <returns>Plugin's image.</returns> + [HttpGet("{pluginId}/{version}/Image")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + [AllowAnonymous] + public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) + { + return NotFound(); } - /// <summary> - /// Gets a plugin's image. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="200">Plugin image returned.</response> - /// <returns>Plugin's image.</returns> - [HttpGet("{pluginId}/{version}/Image")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - [AllowAnonymous] - public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty); + if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath)) { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } - - var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty); - if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath)) - { - return NotFound(); - } - - imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); - return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + return NotFound(); } - /// <summary> - /// Gets a plugin's manifest. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <response code="204">Plugin manifest returned.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/Manifest")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); + imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); + return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + } - if (plugin is not null) - { - return plugin.Manifest; - } + /// <summary> + /// Gets a plugin's manifest. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin manifest returned.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/Manifest")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); - return NotFound(); + if (plugin is not null) + { + return plugin.Manifest; } + + return NotFound(); } } diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs index 6dbcdae228..14f5265aa7 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -1,8 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -using Jellyfin.Api.Constants; -using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; @@ -13,126 +11,119 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Quick connect controller. +/// </summary> +public class QuickConnectController : BaseJellyfinApiController { + private readonly IQuickConnect _quickConnect; + private readonly IAuthorizationContext _authContext; + /// <summary> - /// Quick connect controller. + /// Initializes a new instance of the <see cref="QuickConnectController"/> class. /// </summary> - public class QuickConnectController : BaseJellyfinApiController + /// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext) { - private readonly IQuickConnect _quickConnect; - private readonly IAuthorizationContext _authContext; + _quickConnect = quickConnect; + _authContext = authContext; + } - /// <summary> - /// Initializes a new instance of the <see cref="QuickConnectController"/> class. - /// </summary> - /// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param> - /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> - public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext) - { - _quickConnect = quickConnect; - _authContext = authContext; - } + /// <summary> + /// Gets the current quick connect state. + /// </summary> + /// <response code="200">Quick connect state returned.</response> + /// <returns>Whether Quick Connect is enabled on the server or not.</returns> + [HttpGet("Enabled")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<bool> GetQuickConnectEnabled() + { + return _quickConnect.IsEnabled; + } - /// <summary> - /// Gets the current quick connect state. - /// </summary> - /// <response code="200">Quick connect state returned.</response> - /// <returns>Whether Quick Connect is enabled on the server or not.</returns> - [HttpGet("Enabled")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<bool> GetQuickConnectEnabled() + /// <summary> + /// Initiate a new quick connect request. + /// </summary> + /// <response code="200">Quick connect request successfully created.</response> + /// <response code="401">Quick connect is not active on this server.</response> + /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns> + [HttpPost("Initiate")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect() + { + try { - return _quickConnect.IsEnabled; + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + return _quickConnect.TryConnect(auth); } - - /// <summary> - /// Initiate a new quick connect request. - /// </summary> - /// <response code="200">Quick connect request successfully created.</response> - /// <response code="401">Quick connect is not active on this server.</response> - /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns> - [HttpPost("Initiate")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect() + catch (AuthenticationException) { - try - { - var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); - return _quickConnect.TryConnect(auth); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + return Unauthorized("Quick connect is disabled"); } + } - /// <summary> - /// Old version of <see cref="InitiateQuickConnect" /> using a GET method. - /// Still available to avoid breaking compatibility. - /// </summary> - /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns> - [Obsolete("Use POST request instead")] - [HttpGet("Initiate")] - [ApiExplorerSettings(IgnoreApi = true)] - public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect(); + /// <summary> + /// Old version of <see cref="InitiateQuickConnect" /> using a GET method. + /// Still available to avoid breaking compatibility. + /// </summary> + /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns> + [Obsolete("Use POST request instead")] + [HttpGet("Initiate")] + [ApiExplorerSettings(IgnoreApi = true)] + public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect(); - /// <summary> - /// Attempts to retrieve authentication information. - /// </summary> - /// <param name="secret">Secret previously returned from the Initiate endpoint.</param> - /// <response code="200">Quick connect result returned.</response> - /// <response code="404">Unknown quick connect secret.</response> - /// <returns>An updated <see cref="QuickConnectResult"/>.</returns> - [HttpGet("Connect")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret) + /// <summary> + /// Attempts to retrieve authentication information. + /// </summary> + /// <param name="secret">Secret previously returned from the Initiate endpoint.</param> + /// <response code="200">Quick connect result returned.</response> + /// <response code="404">Unknown quick connect secret.</response> + /// <returns>An updated <see cref="QuickConnectResult"/>.</returns> + [HttpGet("Connect")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret) + { + try { - try - { - return _quickConnect.CheckRequestStatus(secret); - } - catch (ResourceNotFoundException) - { - return NotFound("Unknown secret"); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + return _quickConnect.CheckRequestStatus(secret); } - - /// <summary> - /// Authorizes a pending quick connect request. - /// </summary> - /// <param name="code">Quick connect code to authorize.</param> - /// <param name="userId">The user the authorize. Access to the requested user is required.</param> - /// <response code="200">Quick connect result authorized successfully.</response> - /// <response code="403">Unknown user id.</response> - /// <returns>Boolean indicating if the authorization was successful.</returns> - [HttpPost("Authorize")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null) + catch (ResourceNotFoundException) + { + return NotFound("Unknown secret"); + } + catch (AuthenticationException) { - var currentUserId = User.GetUserId(); - var actualUserId = userId ?? currentUserId; + return Unauthorized("Quick connect is disabled"); + } + } - if (actualUserId.Equals(default) || (!userId.Equals(currentUserId) && !User.IsInRole(UserRoles.Administrator))) - { - return Forbid("Unknown user id"); - } + /// <summary> + /// Authorizes a pending quick connect request. + /// </summary> + /// <param name="code">Quick connect code to authorize.</param> + /// <param name="userId">The user the authorize. Access to the requested user is required.</param> + /// <response code="200">Quick connect result authorized successfully.</response> + /// <response code="403">Unknown user id.</response> + /// <returns>Boolean indicating if the authorization was successful.</returns> + [HttpPost("Authorize")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null) + { + userId = RequestHelpers.GetUserId(User, userId); - try - { - return await _quickConnect.AuthorizeRequest(actualUserId, code).ConfigureAwait(false); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + try + { + return await _quickConnect.AuthorizeRequest(userId.Value, code).ConfigureAwait(false); + } + catch (AuthenticationException) + { + return Unauthorized("Quick connect is disabled"); } } } diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index da9e8cf90d..5c77db2407 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -15,165 +15,164 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Remote Images Controller. +/// </summary> +[Route("")] +public class RemoteImageController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly IServerApplicationPaths _applicationPaths; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="RemoteImageController"/> class. + /// </summary> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public RemoteImageController( + IProviderManager providerManager, + IServerApplicationPaths applicationPaths, + ILibraryManager libraryManager) + { + _providerManager = providerManager; + _applicationPaths = applicationPaths; + _libraryManager = libraryManager; + } + /// <summary> - /// Remote Images Controller. + /// Gets available remote images for an item. /// </summary> - [Route("")] - public class RemoteImageController : BaseJellyfinApiController + /// <param name="itemId">Item Id.</param> + /// <param name="type">The image type.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="providerName">Optional. The image provider to use.</param> + /// <param name="includeAllLanguages">Optional. Include all languages.</param> + /// <response code="200">Remote Images returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>Remote Image Result.</returns> + [HttpGet("Items/{itemId}/RemoteImages")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<RemoteImageResult>> GetRemoteImages( + [FromRoute, Required] Guid itemId, + [FromQuery] ImageType? type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? providerName, + [FromQuery] bool includeAllLanguages = false) { - private readonly IProviderManager _providerManager; - private readonly IServerApplicationPaths _applicationPaths; - private readonly ILibraryManager _libraryManager; - - /// <summary> - /// Initializes a new instance of the <see cref="RemoteImageController"/> class. - /// </summary> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public RemoteImageController( - IProviderManager providerManager, - IServerApplicationPaths applicationPaths, - ILibraryManager libraryManager) + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _providerManager = providerManager; - _applicationPaths = applicationPaths; - _libraryManager = libraryManager; + return NotFound(); } - /// <summary> - /// Gets available remote images for an item. - /// </summary> - /// <param name="itemId">Item Id.</param> - /// <param name="type">The image type.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="providerName">Optional. The image provider to use.</param> - /// <param name="includeAllLanguages">Optional. Include all languages.</param> - /// <response code="200">Remote Images returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>Remote Image Result.</returns> - [HttpGet("Items/{itemId}/RemoteImages")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<RemoteImageResult>> GetRemoteImages( - [FromRoute, Required] Guid itemId, - [FromQuery] ImageType? type, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? providerName, - [FromQuery] bool includeAllLanguages = false) + var images = await _providerManager.GetAvailableRemoteImages( + item, + new RemoteImageQuery(providerName ?? string.Empty) + { + IncludeAllLanguages = includeAllLanguages, + IncludeDisabledProviders = true, + ImageType = type + }, + CancellationToken.None) + .ConfigureAwait(false); + + var imageArray = images.ToArray(); + var allProviders = _providerManager.GetRemoteImageProviderInfo(item); + if (type.HasValue) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - var images = await _providerManager.GetAvailableRemoteImages( - item, - new RemoteImageQuery(providerName ?? string.Empty) - { - IncludeAllLanguages = includeAllLanguages, - IncludeDisabledProviders = true, - ImageType = type - }, - CancellationToken.None) - .ConfigureAwait(false); - - var imageArray = images.ToArray(); - var allProviders = _providerManager.GetRemoteImageProviderInfo(item); - if (type.HasValue) - { - allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value)); - } - - var result = new RemoteImageResult - { - TotalRecordCount = imageArray.Length, - Providers = allProviders.Select(o => o.Name) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() - }; - - if (startIndex.HasValue) - { - imageArray = imageArray.Skip(startIndex.Value).ToArray(); - } - - if (limit.HasValue) - { - imageArray = imageArray.Take(limit.Value).ToArray(); - } - - result.Images = imageArray; - return result; + allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value)); } - /// <summary> - /// Gets available remote image providers for an item. - /// </summary> - /// <param name="itemId">Item Id.</param> - /// <response code="200">Returned remote image providers.</response> - /// <response code="404">Item not found.</response> - /// <returns>List of remote image providers.</returns> - [HttpGet("Items/{itemId}/RemoteImages/Providers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId) + var result = new RemoteImageResult { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + TotalRecordCount = imageArray.Length, + Providers = allProviders.Select(o => o.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; - return Ok(_providerManager.GetRemoteImageProviderInfo(item)); + if (startIndex.HasValue) + { + imageArray = imageArray.Skip(startIndex.Value).ToArray(); } - /// <summary> - /// Downloads a remote image for an item. - /// </summary> - /// <param name="itemId">Item Id.</param> - /// <param name="type">The image type.</param> - /// <param name="imageUrl">The image url.</param> - /// <response code="204">Remote image downloaded.</response> - /// <response code="404">Remote image not found.</response> - /// <returns>Download status.</returns> - [HttpPost("Items/{itemId}/RemoteImages/Download")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DownloadRemoteImage( - [FromRoute, Required] Guid itemId, - [FromQuery, Required] ImageType type, - [FromQuery] string? imageUrl) + if (limit.HasValue) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + imageArray = imageArray.Take(limit.Value).ToArray(); + } - await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) - .ConfigureAwait(false); + result.Images = imageArray; + return result; + } - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return NoContent(); + /// <summary> + /// Gets available remote image providers for an item. + /// </summary> + /// <param name="itemId">Item Id.</param> + /// <response code="200">Returned remote image providers.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of remote image providers.</returns> + [HttpGet("Items/{itemId}/RemoteImages/Providers")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets the full cache path. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.String.</returns> - private string GetFullCachePath(string filename) + return Ok(_providerManager.GetRemoteImageProviderInfo(item)); + } + + /// <summary> + /// Downloads a remote image for an item. + /// </summary> + /// <param name="itemId">Item Id.</param> + /// <param name="type">The image type.</param> + /// <param name="imageUrl">The image url.</param> + /// <response code="204">Remote image downloaded.</response> + /// <response code="404">Remote image not found.</response> + /// <returns>Download status.</returns> + [HttpPost("Items/{itemId}/RemoteImages/Download")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DownloadRemoteImage( + [FromRoute, Required] Guid itemId, + [FromQuery, Required] ImageType type, + [FromQuery] string? imageUrl) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); + return NotFound(); } + + await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) + .ConfigureAwait(false); + + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Gets the full cache path. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + private string GetFullCachePath(string filename) + { + return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); } } diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs index 832e145050..c8fa11ac62 100644 --- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -8,154 +8,153 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Scheduled Tasks Controller. +/// </summary> +[Authorize(Policy = Policies.RequiresElevation)] +public class ScheduledTasksController : BaseJellyfinApiController { + private readonly ITaskManager _taskManager; + /// <summary> - /// Scheduled Tasks Controller. + /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] - public class ScheduledTasksController : BaseJellyfinApiController + /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> + public ScheduledTasksController(ITaskManager taskManager) { - private readonly ITaskManager _taskManager; + _taskManager = taskManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class. - /// </summary> - /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> - public ScheduledTasksController(ITaskManager taskManager) - { - _taskManager = taskManager; - } + /// <summary> + /// Get tasks. + /// </summary> + /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param> + /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param> + /// <response code="200">Scheduled tasks retrieved.</response> + /// <returns>The list of scheduled tasks.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<TaskInfo> GetTasks( + [FromQuery] bool? isHidden, + [FromQuery] bool? isEnabled) + { + IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name); - /// <summary> - /// Get tasks. - /// </summary> - /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param> - /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param> - /// <response code="200">Scheduled tasks retrieved.</response> - /// <returns>The list of scheduled tasks.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<TaskInfo> GetTasks( - [FromQuery] bool? isHidden, - [FromQuery] bool? isEnabled) + foreach (var task in tasks) { - IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name); - - foreach (var task in tasks) + if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask) { - if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask) + if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden) { - if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden) - { - continue; - } - - if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled) - { - continue; - } + continue; } - yield return ScheduledTaskHelpers.GetTaskInfo(task); + if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled) + { + continue; + } } - } - /// <summary> - /// Get task by id. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <response code="200">Task retrieved.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns> - [HttpGet("{taskId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(i => - string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); + yield return ScheduledTaskHelpers.GetTaskInfo(task); + } + } - if (task is null) - { - return NotFound(); - } + /// <summary> + /// Get task by id. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="200">Task retrieved.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns> + [HttpGet("{taskId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(i => + string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); - return ScheduledTaskHelpers.GetTaskInfo(task); + if (task is null) + { + return NotFound(); } - /// <summary> - /// Start specified task. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <response code="204">Task started.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> - [HttpPost("Running/{taskId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult StartTask([FromRoute, Required] string taskId) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(o => - o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + return ScheduledTaskHelpers.GetTaskInfo(task); + } - if (task is null) - { - return NotFound(); - } + /// <summary> + /// Start specified task. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="204">Task started.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpPost("Running/{taskId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult StartTask([FromRoute, Required] string taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); - _taskManager.Execute(task, new TaskOptions()); - return NoContent(); + if (task is null) + { + return NotFound(); } - /// <summary> - /// Stop specified task. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <response code="204">Task stopped.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> - [HttpDelete("Running/{taskId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult StopTask([FromRoute, Required] string taskId) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(o => - o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + _taskManager.Execute(task, new TaskOptions()); + return NoContent(); + } - if (task is null) - { - return NotFound(); - } + /// <summary> + /// Stop specified task. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="204">Task stopped.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpDelete("Running/{taskId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult StopTask([FromRoute, Required] string taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); - _taskManager.Cancel(task); - return NoContent(); + if (task is null) + { + return NotFound(); } - /// <summary> - /// Update specified task triggers. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <param name="triggerInfos">Triggers.</param> - /// <response code="204">Task triggers updated.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> - [HttpPost("{taskId}/Triggers")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateTask( - [FromRoute, Required] string taskId, - [FromBody, Required] TaskTriggerInfo[] triggerInfos) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(o => - o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); - if (task is null) - { - return NotFound(); - } + _taskManager.Cancel(task); + return NoContent(); + } - task.Triggers = triggerInfos; - return NoContent(); + /// <summary> + /// Update specified task triggers. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <param name="triggerInfos">Triggers.</param> + /// <response code="204">Task triggers updated.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpPost("{taskId}/Triggers")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateTask( + [FromRoute, Required] string taskId, + [FromBody, Required] TaskTriggerInfo[] triggerInfos) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + if (task is null) + { + return NotFound(); } + + task.Triggers = triggerInfos; + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 3b7719f373..387b3ea5a6 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -3,7 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -20,247 +20,247 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Search controller. +/// </summary> +[Route("Search/Hints")] +[Authorize] +public class SearchController : BaseJellyfinApiController { + private readonly ISearchEngine _searchEngine; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IImageProcessor _imageProcessor; + /// <summary> - /// Search controller. + /// Initializes a new instance of the <see cref="SearchController"/> class. /// </summary> - [Route("Search/Hints")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class SearchController : BaseJellyfinApiController + /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> 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, + ILibraryManager libraryManager, + IDtoService dtoService, + IImageProcessor imageProcessor) { - private readonly ISearchEngine _searchEngine; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IImageProcessor _imageProcessor; + _searchEngine = searchEngine; + _libraryManager = libraryManager; + _dtoService = dtoService; + _imageProcessor = imageProcessor; + } - /// <summary> - /// Initializes a new instance of the <see cref="SearchController"/> class. - /// </summary> - /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> 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, - ILibraryManager libraryManager, - IDtoService dtoService, - IImageProcessor imageProcessor) + /// <summary> + /// Gets the search hint result. + /// </summary> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param> + /// <param name="searchTerm">The search term to filter on.</param> + /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimited.</param> + /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimited.</param> + /// <param name="parentId">If specified, only children of the parent are returned.</param> + /// <param name="isMovie">Optional filter for movies.</param> + /// <param name="isSeries">Optional filter for series.</param> + /// <param name="isNews">Optional filter for news.</param> + /// <param name="isKids">Optional filter for kids.</param> + /// <param name="isSports">Optional filter for sports.</param> + /// <param name="includePeople">Optional filter whether to include people.</param> + /// <param name="includeMedia">Optional filter whether to include media.</param> + /// <param name="includeGenres">Optional filter whether to include genres.</param> + /// <param name="includeStudios">Optional filter whether to include studios.</param> + /// <param name="includeArtists">Optional filter whether to include artists.</param> + /// <response code="200">Search hint returned.</response> + /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns> + [HttpGet] + [Description("Gets search hints based on a search term")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<SearchHintResult> GetSearchHints( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] Guid? userId, + [FromQuery, Required] string searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery] Guid? parentId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool includePeople = true, + [FromQuery] bool includeMedia = true, + [FromQuery] bool includeGenres = true, + [FromQuery] bool includeStudios = true, + [FromQuery] bool includeArtists = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var result = _searchEngine.GetSearchHints(new SearchQuery { - _searchEngine = searchEngine; - _libraryManager = libraryManager; - _dtoService = dtoService; - _imageProcessor = imageProcessor; - } + Limit = limit, + SearchTerm = searchTerm, + IncludeArtists = includeArtists, + IncludeGenres = includeGenres, + IncludeMedia = includeMedia, + IncludePeople = includePeople, + IncludeStudios = includeStudios, + StartIndex = startIndex, + UserId = userId.Value, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + MediaTypes = mediaTypes, + ParentId = parentId, - /// <summary> - /// Gets the search hint result. - /// </summary> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param> - /// <param name="searchTerm">The search term to filter on.</param> - /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimited.</param> - /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimited.</param> - /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimited.</param> - /// <param name="parentId">If specified, only children of the parent are returned.</param> - /// <param name="isMovie">Optional filter for movies.</param> - /// <param name="isSeries">Optional filter for series.</param> - /// <param name="isNews">Optional filter for news.</param> - /// <param name="isKids">Optional filter for kids.</param> - /// <param name="isSports">Optional filter for sports.</param> - /// <param name="includePeople">Optional filter whether to include people.</param> - /// <param name="includeMedia">Optional filter whether to include media.</param> - /// <param name="includeGenres">Optional filter whether to include genres.</param> - /// <param name="includeStudios">Optional filter whether to include studios.</param> - /// <param name="includeArtists">Optional filter whether to include artists.</param> - /// <response code="200">Search hint returned.</response> - /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns> - [HttpGet] - [Description("Gets search hints based on a search term")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<SearchHintResult> GetSearchHints( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] Guid? userId, - [FromQuery, Required] string searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery] Guid? parentId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] bool includePeople = true, - [FromQuery] bool includeMedia = true, - [FromQuery] bool includeGenres = true, - [FromQuery] bool includeStudios = true, - [FromQuery] bool includeArtists = true) - { - var result = _searchEngine.GetSearchHints(new SearchQuery - { - Limit = limit, - SearchTerm = searchTerm, - IncludeArtists = includeArtists, - IncludeGenres = includeGenres, - IncludeMedia = includeMedia, - IncludePeople = includePeople, - IncludeStudios = includeStudios, - StartIndex = startIndex, - UserId = userId ?? Guid.Empty, - IncludeItemTypes = includeItemTypes, - ExcludeItemTypes = excludeItemTypes, - MediaTypes = mediaTypes, - ParentId = parentId, + IsKids = isKids, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsSports = isSports + }); - IsKids = isKids, - IsMovie = isMovie, - IsNews = isNews, - IsSeries = isSeries, - IsSports = isSports - }); + return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); + } - return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); - } + /// <summary> + /// Gets the search hint result. + /// </summary> + /// <param name="hintInfo">The hint info.</param> + /// <returns>SearchHintResult.</returns> + private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) + { + var item = hintInfo.Item; - /// <summary> - /// Gets the search hint result. - /// </summary> - /// <param name="hintInfo">The hint info.</param> - /// <returns>SearchHintResult.</returns> - private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) + var result = new SearchHint { - var item = hintInfo.Item; - - var result = new SearchHint - { - Name = item.Name, - IndexNumber = item.IndexNumber, - ParentIndexNumber = item.ParentIndexNumber, - Id = item.Id, - Type = item.GetBaseItemKind(), - MediaType = item.MediaType, - MatchedTerm = hintInfo.MatchedTerm, - RunTimeTicks = item.RunTimeTicks, - ProductionYear = item.ProductionYear, - ChannelId = item.ChannelId, - EndDate = item.EndDate - }; + Name = item.Name, + IndexNumber = item.IndexNumber, + ParentIndexNumber = item.ParentIndexNumber, + Id = item.Id, + Type = item.GetBaseItemKind(), + MediaType = item.MediaType, + MatchedTerm = hintInfo.MatchedTerm, + RunTimeTicks = item.RunTimeTicks, + ProductionYear = item.ProductionYear, + ChannelId = item.ChannelId, + EndDate = item.EndDate + }; #pragma warning disable CS0618 - // Kept for compatibility with older clients - result.ItemId = result.Id; + // Kept for compatibility with older clients + result.ItemId = result.Id; #pragma warning restore CS0618 - if (item.IsFolder) - { - result.IsFolder = true; - } - - var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); + if (item.IsFolder) + { + result.IsFolder = true; + } - if (primaryImageTag is not null) - { - result.PrimaryImageTag = primaryImageTag; - result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); - } + var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); - SetThumbImageInfo(result, item); - SetBackdropImageInfo(result, item); + if (primaryImageTag is not null) + { + result.PrimaryImageTag = primaryImageTag; + result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); + } - switch (item) - { - case IHasSeries hasSeries: - result.Series = hasSeries.SeriesName; - break; - case LiveTvProgram program: - result.StartDate = program.StartDate; - break; - case Series series: - if (series.Status.HasValue) - { - result.Status = series.Status.Value.ToString(); - } + SetThumbImageInfo(result, item); + SetBackdropImageInfo(result, item); - break; - case MusicAlbum album: - result.Artists = album.Artists; - result.AlbumArtist = album.AlbumArtist; - break; - case Audio song: - result.AlbumArtist = song.AlbumArtists?.FirstOrDefault(); - result.Artists = song.Artists; + switch (item) + { + case IHasSeries hasSeries: + result.Series = hasSeries.SeriesName; + break; + case LiveTvProgram program: + result.StartDate = program.StartDate; + break; + case Series series: + if (series.Status.HasValue) + { + result.Status = series.Status.Value.ToString(); + } - MusicAlbum musicAlbum = song.AlbumEntity; + break; + case MusicAlbum album: + result.Artists = album.Artists; + result.AlbumArtist = album.AlbumArtist; + break; + case Audio song: + result.AlbumArtist = song.AlbumArtists?.FirstOrDefault(); + result.Artists = song.Artists; - if (musicAlbum is not null) - { - result.Album = musicAlbum.Name; - result.AlbumId = musicAlbum.Id; - } - else - { - result.Album = song.Album; - } + MusicAlbum musicAlbum = song.AlbumEntity; - break; - } + if (musicAlbum is not null) + { + result.Album = musicAlbum.Name; + result.AlbumId = musicAlbum.Id; + } + else + { + result.Album = song.Album; + } - if (!item.ChannelId.Equals(default)) - { - var channel = _libraryManager.GetItemById(item.ChannelId); - result.ChannelName = channel?.Name; - } + break; + } - return result; + if (!item.ChannelId.Equals(default)) + { + var channel = _libraryManager.GetItemById(item.ChannelId); + result.ChannelName = channel?.Name; } - private void SetThumbImageInfo(SearchHint hint, BaseItem item) + return result; + } + + private void SetThumbImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + + if (itemWithImage is null && item is Episode) { - var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); + } - if (itemWithImage is null && item is Episode) - { - itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); - } + itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb); - itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb); + if (itemWithImage is not null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); - if (itemWithImage is not null) + if (tag is not null) { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); - - if (tag is not null) - { - hint.ThumbImageTag = tag; - hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } + hint.ThumbImageTag = tag; + hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); } } + } + + private void SetBackdropImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) + ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop); - private void SetBackdropImageInfo(SearchHint hint, BaseItem item) + if (itemWithImage is not null) { - var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) - ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop); + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); - if (itemWithImage is not null) + if (tag is not null) { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); - - if (tag is not null) - { - hint.BackdropImageTag = tag; - hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } + hint.BackdropImageTag = tag; + hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); } } + } - private T? GetParentWithImage<T>(BaseItem item, ImageType type) - where T : BaseItem - { - return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type)); - } + private T? GetParentWithImage<T>(BaseItem item, ImageType type) + where T : BaseItem + { + return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type)); } } diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 25f9301351..e93456de66 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -19,480 +19,483 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The session controller. +/// </summary> +[Route("")] +public class SessionController : BaseJellyfinApiController { + private readonly ISessionManager _sessionManager; + private readonly IUserManager _userManager; + private readonly IDeviceManager _deviceManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SessionController"/> class. + /// </summary> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + public SessionController( + ISessionManager sessionManager, + IUserManager userManager, + IDeviceManager deviceManager) + { + _sessionManager = sessionManager; + _userManager = userManager; + _deviceManager = deviceManager; + } + /// <summary> - /// The session controller. + /// Gets a list of sessions. /// </summary> - [Route("")] - public class SessionController : BaseJellyfinApiController + /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param> + /// <param name="deviceId">Filter by device Id.</param> + /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param> + /// <response code="200">List of sessions returned.</response> + /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns> + [HttpGet("Sessions")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<SessionInfo>> GetSessions( + [FromQuery] Guid? controllableByUserId, + [FromQuery] string? deviceId, + [FromQuery] int? activeWithinSeconds) { - private readonly ISessionManager _sessionManager; - private readonly IUserManager _userManager; - private readonly IDeviceManager _deviceManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SessionController"/> class. - /// </summary> - /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> - /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> - /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> - public SessionController( - ISessionManager sessionManager, - IUserManager userManager, - IDeviceManager deviceManager) + var result = _sessionManager.Sessions; + + if (!string.IsNullOrEmpty(deviceId)) { - _sessionManager = sessionManager; - _userManager = userManager; - _deviceManager = deviceManager; + result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); } - /// <summary> - /// Gets a list of sessions. - /// </summary> - /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param> - /// <param name="deviceId">Filter by device Id.</param> - /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param> - /// <response code="200">List of sessions returned.</response> - /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns> - [HttpGet("Sessions")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<SessionInfo>> GetSessions( - [FromQuery] Guid? controllableByUserId, - [FromQuery] string? deviceId, - [FromQuery] int? activeWithinSeconds) + if (controllableByUserId.HasValue && !controllableByUserId.Equals(default)) { - var result = _sessionManager.Sessions; + result = result.Where(i => i.SupportsRemoteControl); - if (!string.IsNullOrEmpty(deviceId)) + var user = _userManager.GetUserById(controllableByUserId.Value); + if (user is null) { - result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); + return NotFound(); } - if (controllableByUserId.HasValue && !controllableByUserId.Equals(default)) + if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) { - result = result.Where(i => i.SupportsRemoteControl); - - var user = _userManager.GetUserById(controllableByUserId.Value); - - if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) - { - result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value)); - } + result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value)); + } - if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) - { - result = result.Where(i => !i.UserId.Equals(default)); - } + if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) + { + result = result.Where(i => !i.UserId.Equals(default)); + } - if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) - { - var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); - result = result.Where(i => i.LastActivityDate >= minActiveDate); - } + if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) + { + var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); + result = result.Where(i => i.LastActivityDate >= minActiveDate); + } - result = result.Where(i => + result = result.Where(i => + { + if (!string.IsNullOrWhiteSpace(i.DeviceId)) { - if (!string.IsNullOrWhiteSpace(i.DeviceId)) + if (!_deviceManager.CanAccessDevice(user, i.DeviceId)) { - if (!_deviceManager.CanAccessDevice(user, i.DeviceId)) - { - return false; - } + return false; } + } - return true; - }); - } - - return Ok(result); + return true; + }); } - /// <summary> - /// Instructs a session to browse to an item or view. - /// </summary> - /// <param name="sessionId">The session Id.</param> - /// <param name="itemType">The type of item to browse to.</param> - /// <param name="itemId">The Id of the item.</param> - /// <param name="itemName">The name of the item.</param> - /// <response code="204">Instruction sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Viewing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> DisplayContent( - [FromRoute, Required] string sessionId, - [FromQuery, Required] BaseItemKind itemType, - [FromQuery, Required] string itemId, - [FromQuery, Required] string itemName) + return Ok(result); + } + + /// <summary> + /// Instructs a session to browse to an item or view. + /// </summary> + /// <param name="sessionId">The session Id.</param> + /// <param name="itemType">The type of item to browse to.</param> + /// <param name="itemId">The Id of the item.</param> + /// <param name="itemName">The name of the item.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Viewing")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> DisplayContent( + [FromRoute, Required] string sessionId, + [FromQuery, Required] BaseItemKind itemType, + [FromQuery, Required] string itemId, + [FromQuery, Required] string itemName) + { + var command = new BrowseRequest { - var command = new BrowseRequest - { - ItemId = itemId, - ItemName = itemName, - ItemType = itemType - }; - - await _sessionManager.SendBrowseCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - command, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } + ItemId = itemId, + ItemName = itemName, + ItemType = itemType + }; + + await _sessionManager.SendBrowseCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } - /// <summary> - /// Instructs a session to play an item. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param> - /// <param name="itemIds">The ids of the items to play, comma delimited.</param> - /// <param name="startPositionTicks">The starting position of the first item.</param> - /// <param name="mediaSourceId">Optional. The media source id.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param> - /// <param name="startIndex">Optional. The start index.</param> - /// <response code="204">Instruction sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Playing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> Play( - [FromRoute, Required] string sessionId, - [FromQuery, Required] PlayCommand playCommand, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, - [FromQuery] long? startPositionTicks, - [FromQuery] string? mediaSourceId, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] int? startIndex) + /// <summary> + /// Instructs a session to play an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param> + /// <param name="itemIds">The ids of the items to play, comma delimited.</param> + /// <param name="startPositionTicks">The starting position of the first item.</param> + /// <param name="mediaSourceId">Optional. The media source id.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param> + /// <param name="startIndex">Optional. The start index.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Playing")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> Play( + [FromRoute, Required] string sessionId, + [FromQuery, Required] PlayCommand playCommand, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, + [FromQuery] long? startPositionTicks, + [FromQuery] string? mediaSourceId, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? startIndex) + { + var playRequest = new PlayRequest { - var playRequest = new PlayRequest + ItemIds = itemIds, + StartPositionTicks = startPositionTicks, + PlayCommand = playCommand, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + StartIndex = startIndex + }; + + await _sessionManager.SendPlayCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + playRequest, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Issues a playstate command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="PlaystateCommand"/>.</param> + /// <param name="seekPositionTicks">The optional position ticks.</param> + /// <param name="controllingUserId">The optional controlling user id.</param> + /// <response code="204">Playstate command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Playing/{command}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendPlaystateCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] PlaystateCommand command, + [FromQuery] long? seekPositionTicks, + [FromQuery] string? controllingUserId) + { + await _sessionManager.SendPlaystateCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + new PlaystateRequest() { - ItemIds = itemIds, - StartPositionTicks = startPositionTicks, - PlayCommand = playCommand, - MediaSourceId = mediaSourceId, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - StartIndex = startIndex - }; - - await _sessionManager.SendPlayCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - playRequest, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } + Command = command, + ControllingUserId = controllingUserId, + SeekPositionTicks = seekPositionTicks, + }, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } - /// <summary> - /// Issues a playstate command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The <see cref="PlaystateCommand"/>.</param> - /// <param name="seekPositionTicks">The optional position ticks.</param> - /// <param name="controllingUserId">The optional controlling user id.</param> - /// <response code="204">Playstate command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Playing/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendPlaystateCommand( - [FromRoute, Required] string sessionId, - [FromRoute, Required] PlaystateCommand command, - [FromQuery] long? seekPositionTicks, - [FromQuery] string? controllingUserId) + /// <summary> + /// Issues a system command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">System command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/System/{command}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendSystemCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] GeneralCommandType command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var generalCommand = new GeneralCommand { - await _sessionManager.SendPlaystateCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - new PlaystateRequest() - { - Command = command, - ControllingUserId = controllingUserId, - SeekPositionTicks = seekPositionTicks, - }, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } + Name = command, + ControllingUserId = currentSession.UserId + }; - /// <summary> - /// Issues a system command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The command to send.</param> - /// <response code="204">System command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/System/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendSystemCommand( - [FromRoute, Required] string sessionId, - [FromRoute, Required] GeneralCommandType command) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var generalCommand = new GeneralCommand - { - Name = command, - ControllingUserId = currentSession.UserId - }; + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false); - await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Issues a general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">General command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Command/{command}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendGeneralCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] GeneralCommandType command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - /// <summary> - /// Issues a general command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The command to send.</param> - /// <response code="204">General command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Command/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendGeneralCommand( - [FromRoute, Required] string sessionId, - [FromRoute, Required] GeneralCommandType command) + var generalCommand = new GeneralCommand { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + Name = command, + ControllingUserId = currentSession.UserId + }; - var generalCommand = new GeneralCommand - { - Name = command, - ControllingUserId = currentSession.UserId - }; + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None) + .ConfigureAwait(false); - await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Issues a full general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="GeneralCommand"/>.</param> + /// <response code="204">Full general command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Command")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendFullGeneralCommand( + [FromRoute, Required] string sessionId, + [FromBody, Required] GeneralCommand command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - /// <summary> - /// Issues a full general command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The <see cref="GeneralCommand"/>.</param> - /// <response code="204">Full general command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Command")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendFullGeneralCommand( - [FromRoute, Required] string sessionId, - [FromBody, Required] GeneralCommand command) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(command); - ArgumentNullException.ThrowIfNull(command); + command.ControllingUserId = currentSession.UserId; - command.ControllingUserId = currentSession.UserId; + await _sessionManager.SendGeneralCommand( + currentSession.Id, + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); - await _sessionManager.SendGeneralCommand( - currentSession.Id, - sessionId, - command, - CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - return NoContent(); + /// <summary> + /// Issues a command to a client to display a message to the user. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param> + /// <response code="204">Message sent.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Message")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendMessageCommand( + [FromRoute, Required] string sessionId, + [FromBody, Required] MessageCommand command) + { + if (string.IsNullOrWhiteSpace(command.Header)) + { + command.Header = "Message from Server"; } - /// <summary> - /// Issues a command to a client to display a message to the user. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param> - /// <response code="204">Message sent.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Message")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendMessageCommand( - [FromRoute, Required] string sessionId, - [FromBody, Required] MessageCommand command) - { - if (string.IsNullOrWhiteSpace(command.Header)) - { - command.Header = "Message from Server"; - } + await _sessionManager.SendMessageCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); - await _sessionManager.SendMessageCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - command, - CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Adds an additional user to a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User added to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/User/{userId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddUserToSession( + [FromRoute, Required] string sessionId, + [FromRoute, Required] Guid userId) + { + _sessionManager.AddAdditionalUser(sessionId, userId); + return NoContent(); + } - /// <summary> - /// Adds an additional user to a session. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="userId">The user id.</param> - /// <response code="204">User added to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/User/{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult AddUserToSession( - [FromRoute, Required] string sessionId, - [FromRoute, Required] Guid userId) - { - _sessionManager.AddAdditionalUser(sessionId, userId); - return NoContent(); - } + /// <summary> + /// Removes an additional user from a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User removed from session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Sessions/{sessionId}/User/{userId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveUserFromSession( + [FromRoute, Required] string sessionId, + [FromRoute, Required] Guid userId) + { + _sessionManager.RemoveAdditionalUser(sessionId, userId); + return NoContent(); + } - /// <summary> - /// Removes an additional user from a session. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="userId">The user id.</param> - /// <response code="204">User removed from session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Sessions/{sessionId}/User/{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveUserFromSession( - [FromRoute, Required] string sessionId, - [FromRoute, Required] Guid userId) + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param> + /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param> + /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param> + /// <param name="supportsSync">Determines whether sync is supported.</param> + /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param> + /// <response code="204">Capabilities posted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Capabilities")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> PostCapabilities( + [FromQuery] string? id, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, + [FromQuery] bool supportsMediaControl = false, + [FromQuery] bool supportsSync = false, + [FromQuery] bool supportsPersistentIdentifier = true) + { + if (string.IsNullOrWhiteSpace(id)) { - _sessionManager.RemoveAdditionalUser(sessionId, userId); - return NoContent(); + id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); } - /// <summary> - /// Updates capabilities for a device. - /// </summary> - /// <param name="id">The session id.</param> - /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param> - /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param> - /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param> - /// <param name="supportsSync">Determines whether sync is supported.</param> - /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param> - /// <response code="204">Capabilities posted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Capabilities")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> PostCapabilities( - [FromQuery] string? id, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, - [FromQuery] bool supportsMediaControl = false, - [FromQuery] bool supportsSync = false, - [FromQuery] bool supportsPersistentIdentifier = true) + _sessionManager.ReportCapabilities(id, new ClientCapabilities { - if (string.IsNullOrWhiteSpace(id)) - { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - } - - _sessionManager.ReportCapabilities(id, new ClientCapabilities - { - PlayableMediaTypes = playableMediaTypes, - SupportedCommands = supportedCommands, - SupportsMediaControl = supportsMediaControl, - SupportsSync = supportsSync, - SupportsPersistentIdentifier = supportsPersistentIdentifier - }); - return NoContent(); - } + PlayableMediaTypes = playableMediaTypes, + SupportedCommands = supportedCommands, + SupportsMediaControl = supportsMediaControl, + SupportsSync = supportsSync, + SupportsPersistentIdentifier = supportsPersistentIdentifier + }); + return NoContent(); + } - /// <summary> - /// Updates capabilities for a device. - /// </summary> - /// <param name="id">The session id.</param> - /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param> - /// <response code="204">Capabilities updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Capabilities/Full")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> PostFullCapabilities( - [FromQuery] string? id, - [FromBody, Required] ClientCapabilitiesDto capabilities) + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param> + /// <response code="204">Capabilities updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Capabilities/Full")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> PostFullCapabilities( + [FromQuery] string? id, + [FromBody, Required] ClientCapabilitiesDto capabilities) + { + if (string.IsNullOrWhiteSpace(id)) { - if (string.IsNullOrWhiteSpace(id)) - { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - } + id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + } - _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); + _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); - return NoContent(); - } + return NoContent(); + } - /// <summary> - /// Reports that a session is viewing an item. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="itemId">The item id.</param> - /// <response code="204">Session reported to server.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Viewing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportViewing( - [FromQuery] string? sessionId, - [FromQuery, Required] string? itemId) - { - string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + /// <summary> + /// Reports that a session is viewing an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="itemId">The item id.</param> + /// <response code="204">Session reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Viewing")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportViewing( + [FromQuery] string? sessionId, + [FromQuery, Required] string? itemId) + { + string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - _sessionManager.ReportNowViewingItem(session, itemId); - return NoContent(); - } + _sessionManager.ReportNowViewingItem(session, itemId); + return NoContent(); + } - /// <summary> - /// Reports that a session has ended. - /// </summary> - /// <response code="204">Session end reported to server.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Logout")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportSessionEnded() - { - await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Reports that a session has ended. + /// </summary> + /// <response code="204">Session end reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Logout")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportSessionEnded() + { + await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Get all auth providers. - /// </summary> - /// <response code="200">Auth providers retrieved.</response> - /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> - [HttpGet("Auth/Providers")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() - { - return _userManager.GetAuthenticationProviders(); - } + /// <summary> + /// Get all auth providers. + /// </summary> + /// <response code="200">Auth providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> + [HttpGet("Auth/Providers")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() + { + return _userManager.GetAuthenticationProviders(); + } - /// <summary> - /// Get all password reset providers. - /// </summary> - /// <response code="200">Password reset providers retrieved.</response> - /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> - [HttpGet("Auth/PasswordResetProviders")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.RequiresElevation)] - public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() - { - return _userManager.GetPasswordResetProviders(); - } + /// <summary> + /// Get all password reset providers. + /// </summary> + /// <response code="200">Password reset providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> + [HttpGet("Auth/PasswordResetProviders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.RequiresElevation)] + public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() + { + return _userManager.GetPasswordResetProviders(); } } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index eec5779e64..1098733b2c 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -10,141 +10,144 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The startup wizard controller. +/// </summary> +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class StartupController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _config; + private readonly IUserManager _userManager; + /// <summary> - /// The startup wizard controller. + /// Initializes a new instance of the <see cref="StartupController" /> class. /// </summary> - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class StartupController : BaseJellyfinApiController + /// <param name="config">The server configuration manager.</param> + /// <param name="userManager">The user manager.</param> + public StartupController(IServerConfigurationManager config, IUserManager userManager) { - private readonly IServerConfigurationManager _config; - private readonly IUserManager _userManager; + _config = config; + _userManager = userManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="StartupController" /> class. - /// </summary> - /// <param name="config">The server configuration manager.</param> - /// <param name="userManager">The user manager.</param> - public StartupController(IServerConfigurationManager config, IUserManager userManager) - { - _config = config; - _userManager = userManager; - } + /// <summary> + /// Completes the startup wizard. + /// </summary> + /// <response code="204">Startup wizard completed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Complete")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CompleteWizard() + { + _config.Configuration.IsStartupWizardCompleted = true; + _config.SaveConfiguration(); + return NoContent(); + } - /// <summary> - /// Completes the startup wizard. - /// </summary> - /// <response code="204">Startup wizard completed.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Complete")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CompleteWizard() + /// <summary> + /// Gets the initial startup wizard configuration. + /// </summary> + /// <response code="200">Initial startup wizard configuration retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<StartupConfigurationDto> GetStartupConfiguration() + { + return new StartupConfigurationDto { - _config.Configuration.IsStartupWizardCompleted = true; - _config.SaveConfiguration(); - return NoContent(); - } + UICulture = _config.Configuration.UICulture, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage + }; + } - /// <summary> - /// Gets the initial startup wizard configuration. - /// </summary> - /// <response code="200">Initial startup wizard configuration retrieved.</response> - /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<StartupConfigurationDto> GetStartupConfiguration() - { - return new StartupConfigurationDto - { - UICulture = _config.Configuration.UICulture, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage - }; - } + /// <summary> + /// Sets the initial startup wizard configuration. + /// </summary> + /// <param name="startupConfiguration">The updated startup configuration.</param> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Configuration")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) + { + _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty; + _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty; + _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty; + _config.SaveConfiguration(); + return NoContent(); + } - /// <summary> - /// Sets the initial startup wizard configuration. - /// </summary> - /// <param name="startupConfiguration">The updated startup configuration.</param> - /// <response code="204">Configuration saved.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Configuration")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) - { - _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty; - _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty; - _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty; - _config.SaveConfiguration(); - return NoContent(); - } + /// <summary> + /// Sets remote access and UPnP. + /// </summary> + /// <param name="startupRemoteAccessDto">The startup remote access dto.</param> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("RemoteAccess")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) + { + NetworkConfiguration settings = _config.GetNetworkConfiguration(); + settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; + settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; + _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings); + return NoContent(); + } - /// <summary> - /// Sets remote access and UPnP. - /// </summary> - /// <param name="startupRemoteAccessDto">The startup remote access dto.</param> - /// <response code="204">Configuration saved.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("RemoteAccess")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) + /// <summary> + /// Gets the first user. + /// </summary> + /// <response code="200">Initial user retrieved.</response> + /// <returns>The first user.</returns> + [HttpGet("User")] + [HttpGet("FirstUser", Name = "GetFirstUser_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + 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(); + return new StartupUserDto { - NetworkConfiguration settings = _config.GetNetworkConfiguration(); - settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; - settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; - _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings); - return NoContent(); - } + Name = user.Username, + Password = user.Password + }; + } - /// <summary> - /// Gets the first user. - /// </summary> - /// <response code="200">Initial user retrieved.</response> - /// <returns>The first user.</returns> - [HttpGet("User")] - [HttpGet("FirstUser", Name = "GetFirstUser_2")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<StartupUserDto> GetFirstUser() + /// <summary> + /// Sets the user name and password. + /// </summary> + /// <param name="startupUserDto">The DTO containing username and password.</param> + /// <response code="204">Updated user name and password.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous update operation. + /// The task result contains a <see cref="NoContentResult"/> indicating success. + /// </returns> + [HttpPost("User")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) + { + var user = _userManager.Users.First(); + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { - // TODO: Remove this method when startup wizard no longer requires an existing user. - await _userManager.InitializeAsync().ConfigureAwait(false); - var user = _userManager.Users.First(); - return new StartupUserDto - { - Name = user.Username, - Password = user.Password - }; + return BadRequest("Password must not be empty"); } - /// <summary> - /// Sets the user name and password. - /// </summary> - /// <param name="startupUserDto">The DTO containing username and password.</param> - /// <response code="204">Updated user name and password.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous update operation. - /// The task result contains a <see cref="NoContentResult"/> indicating success. - /// </returns> - [HttpPost("User")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) + if (startupUserDto.Name is not null) { - var user = _userManager.Users.First(); - - if (startupUserDto.Name is not null) - { - user.Username = startupUserDto.Name; - } - - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + user.Username = startupUserDto.Name; + } - if (!string.IsNullOrEmpty(startupUserDto.Password)) - { - await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); - } + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - return NoContent(); + if (!string.IsNullOrEmpty(startupUserDto.Password)) + { + await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); } + + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 1288fb5124..f434f60f51 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -16,141 +15,142 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Studios controller. +/// </summary> +[Authorize] +public class StudiosController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// <summary> - /// Studios controller. + /// Initializes a new instance of the <see cref="StudiosController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class StudiosController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public StudiosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// <summary> - /// Initializes a new instance of the <see cref="StudiosController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public StudiosController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + /// <summary> + /// Gets all studios from a given item, folder, or the entire library. + /// </summary> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Studios returned.</response> + /// <returns>An <see cref="OkResult"/> containing the studios.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetStudios( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isFavorite, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - /// <summary> - /// Gets all studios from a given item, folder, or the entire library. - /// </summary> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="searchTerm">Optional. Search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Total record count.</param> - /// <response code="200">Studios returned.</response> - /// <returns>An <see cref="OkResult"/> containing the studios.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetStudios( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isFavorite, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + User? user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + var parentItem = _libraryManager.GetParentItem(parentId, userId); - var parentItem = _libraryManager.GetParentItem(parentId, userId); + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount + }; - var query = new InternalItemsQuery(user) + if (parentId.HasValue) + { + if (parentItem is Folder) { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount - }; - - if (parentId.HasValue) + query.AncestorIds = new[] { parentId.Value }; + } + else { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } + query.ItemIds = new[] { parentId.Value }; } - - var result = _libraryManager.GetStudios(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } - /// <summary> - /// Gets a studio by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Studio returned.</response> - /// <returns>An <see cref="OkResult"/> containing the studio.</returns> - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); + var result = _libraryManager.GetStudios(query); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } - var item = _libraryManager.GetStudio(name); - if (userId.HasValue && !userId.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); + /// <summary> + /// Gets a studio by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Studio returned.</response> + /// <returns>An <see cref="OkResult"/> containing the studio.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions().AddClientFields(User); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var item = _libraryManager.GetStudio(name); + if (!userId.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index c3ce1868e2..b3e9d62972 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -30,522 +30,519 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Subtitle controller. +/// </summary> +[Route("")] +public class SubtitleController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILibraryManager _libraryManager; + private readonly ISubtitleManager _subtitleManager; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger<SubtitleController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleController"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> + /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> + public SubtitleController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ISubtitleManager subtitleManager, + ISubtitleEncoder subtitleEncoder, + IMediaSourceManager mediaSourceManager, + IProviderManager providerManager, + IFileSystem fileSystem, + ILogger<SubtitleController> logger) + { + _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; + _subtitleManager = subtitleManager; + _subtitleEncoder = subtitleEncoder; + _mediaSourceManager = mediaSourceManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _logger = logger; + } + /// <summary> - /// Subtitle controller. + /// Deletes an external subtitle file. /// </summary> - [Route("")] - public class SubtitleController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="index">The index of the subtitle file.</param> + /// <response code="204">Subtitle deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Videos/{itemId}/Subtitles/{index}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<Task> DeleteSubtitle( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int index) { - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly ILibraryManager _libraryManager; - private readonly ISubtitleManager _subtitleManager; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - private readonly ILogger<SubtitleController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="SubtitleController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> - /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> - /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> - /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> - public SubtitleController( - IServerConfigurationManager serverConfigurationManager, - ILibraryManager libraryManager, - ISubtitleManager subtitleManager, - ISubtitleEncoder subtitleEncoder, - IMediaSourceManager mediaSourceManager, - IProviderManager providerManager, - IFileSystem fileSystem, - ILogger<SubtitleController> logger) + var item = _libraryManager.GetItemById(itemId); + + if (item is null) { - _serverConfigurationManager = serverConfigurationManager; - _libraryManager = libraryManager; - _subtitleManager = subtitleManager; - _subtitleEncoder = subtitleEncoder; - _mediaSourceManager = mediaSourceManager; - _providerManager = providerManager; - _fileSystem = fileSystem; - _logger = logger; + return NotFound(); } - /// <summary> - /// Deletes an external subtitle file. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="index">The index of the subtitle file.</param> - /// <response code="204">Subtitle deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Videos/{itemId}/Subtitles/{index}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<Task> DeleteSubtitle( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] int index) - { - var item = _libraryManager.GetItemById(itemId); + _subtitleManager.DeleteSubtitles(item, index); + return NoContent(); + } - if (item is null) - { - return NotFound(); - } + /// <summary> + /// Search remote subtitles. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="language">The language of the subtitles.</param> + /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> + /// <response code="200">Subtitles retrieved.</response> + /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> + [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string language, + [FromQuery] bool? isPerfectMatch) + { + var video = (Video)_libraryManager.GetItemById(itemId); - _subtitleManager.DeleteSubtitles(item, index); - return NoContent(); - } + return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Downloads a remote subtitle. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="subtitleId">The subtitle id.</param> + /// <response code="204">Subtitle downloaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> DownloadRemoteSubtitles( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string subtitleId) + { + var video = (Video)_libraryManager.GetItemById(itemId); - /// <summary> - /// Search remote subtitles. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="language">The language of the subtitles.</param> - /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> - /// <response code="200">Subtitles retrieved.</response> - /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> - [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string language, - [FromQuery] bool? isPerfectMatch) + try { - var video = (Video)_libraryManager.GetItemById(itemId); + await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) + .ConfigureAwait(false); - return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); } - - /// <summary> - /// Downloads a remote subtitle. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="subtitleId">The subtitle id.</param> - /// <response code="204">Subtitle downloaded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> DownloadRemoteSubtitles( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string subtitleId) + catch (Exception ex) { - var video = (Video)_libraryManager.GetItemById(itemId); + _logger.LogError(ex, "Error downloading subtitles"); + } - try - { - await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error downloading subtitles"); - } + /// <summary> + /// Gets the remote subtitles. + /// </summary> + /// <param name="id">The item id.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> + [HttpGet("Providers/Subtitles/Subtitles/{id}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesFile("text/*")] + public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id) + { + var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + } - /// <summary> - /// Gets the remote subtitles. - /// </summary> - /// <param name="id">The item id.</param> - /// <response code="200">File returned.</response> - /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> - [HttpGet("Providers/Subtitles/Subtitles/{id}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [Produces(MediaTypeNames.Application.Octet)] - [ProducesFile("text/*")] - public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id) - { - var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); + /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <param name="routeItemId">The (route) item id.</param> + /// <param name="routeMediaSourceId">The (route) media source id.</param> + /// <param name="routeIndex">The (route) subtitle stream index.</param> + /// <param name="routeFormat">The (route) format of the returned subtitle.</param> + /// <param name="itemId">The item id.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="format">The format of the returned subtitle.</param> + /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> + /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> + /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public async Task<ActionResult> GetSubtitle( + [FromRoute, Required] Guid routeItemId, + [FromRoute, Required] string routeMediaSourceId, + [FromRoute, Required] int routeIndex, + [FromRoute, Required] string routeFormat, + [FromQuery, ParameterObsolete] Guid? itemId, + [FromQuery, ParameterObsolete] string? mediaSourceId, + [FromQuery, ParameterObsolete] int? index, + [FromQuery, ParameterObsolete] string? format, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps = false, + [FromQuery] bool addVttTimeMap = false, + [FromQuery] long startPositionTicks = 0) + { + // Set parameters to route value if not provided via query. + itemId ??= routeItemId; + mediaSourceId ??= routeMediaSourceId; + index ??= routeIndex; + format ??= routeFormat; - return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) + { + format = "json"; } - /// <summary> - /// Gets subtitles in a specified format. - /// </summary> - /// <param name="routeItemId">The (route) item id.</param> - /// <param name="routeMediaSourceId">The (route) media source id.</param> - /// <param name="routeIndex">The (route) subtitle stream index.</param> - /// <param name="routeFormat">The (route) format of the returned subtitle.</param> - /// <param name="itemId">The item id.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="index">The subtitle stream index.</param> - /// <param name="format">The format of the returned subtitle.</param> - /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> - /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> - /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> - /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> - /// <response code="200">File returned.</response> - /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> - [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("text/*")] - public async Task<ActionResult> GetSubtitle( - [FromRoute, Required] Guid routeItemId, - [FromRoute, Required] string routeMediaSourceId, - [FromRoute, Required] int routeIndex, - [FromRoute, Required] string routeFormat, - [FromQuery, ParameterObsolete] Guid? itemId, - [FromQuery, ParameterObsolete] string? mediaSourceId, - [FromQuery, ParameterObsolete] int? index, - [FromQuery, ParameterObsolete] string? format, - [FromQuery] long? endPositionTicks, - [FromQuery] bool copyTimestamps = false, - [FromQuery] bool addVttTimeMap = false, - [FromQuery] long startPositionTicks = 0) + if (string.IsNullOrEmpty(format)) { - // Set parameters to route value if not provided via query. - itemId ??= routeItemId; - mediaSourceId ??= routeMediaSourceId; - index ??= routeIndex; - format ??= routeFormat; - - if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) - { - format = "json"; - } - - if (string.IsNullOrEmpty(format)) - { - var item = (Video)_libraryManager.GetItemById(itemId.Value); + var item = (Video)_libraryManager.GetItemById(itemId.Value); - var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); - var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) - .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); + var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); + var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) + .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); - var subtitleStream = mediaSource.MediaStreams - .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + var subtitleStream = mediaSource.MediaStreams + .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); - return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); - } + return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); + } - if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) + if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) + { + Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { - Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - using var reader = new StreamReader(stream); + using var reader = new StreamReader(stream); - var text = await reader.ReadToEndAsync().ConfigureAwait(false); + var text = await reader.ReadToEndAsync().ConfigureAwait(false); - text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); + text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); - return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); - } + return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); } - - return File( - await EncodeSubtitles( - itemId.Value, - mediaSourceId, - index.Value, - format, - startPositionTicks, - endPositionTicks, - copyTimestamps).ConfigureAwait(false), - MimeTypes.GetMimeType("file." + format)); } - /// <summary> - /// Gets subtitles in a specified format. - /// </summary> - /// <param name="routeItemId">The (route) item id.</param> - /// <param name="routeMediaSourceId">The (route) media source id.</param> - /// <param name="routeIndex">The (route) subtitle stream index.</param> - /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param> - /// <param name="routeFormat">The (route) format of the returned subtitle.</param> - /// <param name="itemId">The item id.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="index">The subtitle stream index.</param> - /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> - /// <param name="format">The format of the returned subtitle.</param> - /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> - /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> - /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> - /// <response code="200">File returned.</response> - /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> - [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("text/*")] - public Task<ActionResult> GetSubtitleWithTicks( - [FromRoute, Required] Guid routeItemId, - [FromRoute, Required] string routeMediaSourceId, - [FromRoute, Required] int routeIndex, - [FromRoute, Required] long routeStartPositionTicks, - [FromRoute, Required] string routeFormat, - [FromQuery, ParameterObsolete] Guid? itemId, - [FromQuery, ParameterObsolete] string? mediaSourceId, - [FromQuery, ParameterObsolete] int? index, - [FromQuery, ParameterObsolete] long? startPositionTicks, - [FromQuery, ParameterObsolete] string? format, - [FromQuery] long? endPositionTicks, - [FromQuery] bool copyTimestamps = false, - [FromQuery] bool addVttTimeMap = false) - { - return GetSubtitle( - routeItemId, - routeMediaSourceId, - routeIndex, - routeFormat, - itemId, + return File( + await EncodeSubtitles( + itemId.Value, mediaSourceId, - index, + index.Value, format, + startPositionTicks, endPositionTicks, - copyTimestamps, - addVttTimeMap, - startPositionTicks ?? routeStartPositionTicks); - } + copyTimestamps).ConfigureAwait(false), + MimeTypes.GetMimeType("file." + format)); + } - /// <summary> - /// Gets an HLS subtitle playlist. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="index">The subtitle stream index.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="segmentLength">The subtitle segment length.</param> - /// <response code="200">Subtitle playlist retrieved.</response> - /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> - [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> GetSubtitlePlaylist( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] int index, - [FromRoute, Required] string mediaSourceId, - [FromQuery, Required] int segmentLength) - { - var item = (Video)_libraryManager.GetItemById(itemId); + /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <param name="routeItemId">The (route) item id.</param> + /// <param name="routeMediaSourceId">The (route) media source id.</param> + /// <param name="routeIndex">The (route) subtitle stream index.</param> + /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param> + /// <param name="routeFormat">The (route) format of the returned subtitle.</param> + /// <param name="itemId">The item id.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> + /// <param name="format">The format of the returned subtitle.</param> + /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> + /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> + /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public Task<ActionResult> GetSubtitleWithTicks( + [FromRoute, Required] Guid routeItemId, + [FromRoute, Required] string routeMediaSourceId, + [FromRoute, Required] int routeIndex, + [FromRoute, Required] long routeStartPositionTicks, + [FromRoute, Required] string routeFormat, + [FromQuery, ParameterObsolete] Guid? itemId, + [FromQuery, ParameterObsolete] string? mediaSourceId, + [FromQuery, ParameterObsolete] int? index, + [FromQuery, ParameterObsolete] long? startPositionTicks, + [FromQuery, ParameterObsolete] string? format, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps = false, + [FromQuery] bool addVttTimeMap = false) + { + return GetSubtitle( + routeItemId, + routeMediaSourceId, + routeIndex, + routeFormat, + itemId, + mediaSourceId, + index, + format, + endPositionTicks, + copyTimestamps, + addVttTimeMap, + startPositionTicks ?? routeStartPositionTicks); + } - var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); + /// <summary> + /// Gets an HLS subtitle playlist. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="segmentLength">The subtitle segment length.</param> + /// <response code="200">Subtitle playlist retrieved.</response> + /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> + [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> GetSubtitlePlaylist( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int index, + [FromRoute, Required] string mediaSourceId, + [FromQuery, Required] int segmentLength) + { + var item = (Video)_libraryManager.GetItemById(itemId); - var runtime = mediaSource.RunTimeTicks ?? -1; + var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); - if (runtime <= 0) - { - throw new ArgumentException("HLS Subtitles are not supported for this media."); - } + var runtime = mediaSource.RunTimeTicks ?? -1; - var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; - if (segmentLengthTicks <= 0) - { - throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); - } + if (runtime <= 0) + { + throw new ArgumentException("HLS Subtitles are not supported for this media."); + } - var builder = new StringBuilder(); - builder.AppendLine("#EXTM3U") - .Append("#EXT-X-TARGETDURATION:") - .Append(segmentLength) - .AppendLine() - .AppendLine("#EXT-X-VERSION:3") - .AppendLine("#EXT-X-MEDIA-SEQUENCE:0") - .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; + if (segmentLengthTicks <= 0) + { + throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); + } - long positionTicks = 0; + var builder = new StringBuilder(); + builder.AppendLine("#EXTM3U") + .Append("#EXT-X-TARGETDURATION:") + .Append(segmentLength) + .AppendLine() + .AppendLine("#EXT-X-VERSION:3") + .AppendLine("#EXT-X-MEDIA-SEQUENCE:0") + .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); - var accessToken = User.GetToken(); + long positionTicks = 0; - while (positionTicks < runtime) - { - var remaining = runtime - positionTicks; - var lengthTicks = Math.Min(remaining, segmentLengthTicks); + var accessToken = User.GetToken(); - builder.Append("#EXTINF:") - .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) - .Append(',') - .AppendLine(); + while (positionTicks < runtime) + { + var remaining = runtime - positionTicks; + var lengthTicks = Math.Min(remaining, segmentLengthTicks); - var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); + builder.Append("#EXTINF:") + .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) + .Append(',') + .AppendLine(); - var url = string.Format( - CultureInfo.InvariantCulture, - "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", - positionTicks.ToString(CultureInfo.InvariantCulture), - endPositionTicks.ToString(CultureInfo.InvariantCulture), - accessToken); + var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); - builder.AppendLine(url); + var url = string.Format( + CultureInfo.InvariantCulture, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", + positionTicks.ToString(CultureInfo.InvariantCulture), + endPositionTicks.ToString(CultureInfo.InvariantCulture), + accessToken); - positionTicks += segmentLengthTicks; - } + builder.AppendLine(url); - builder.AppendLine("#EXT-X-ENDLIST"); - return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + positionTicks += segmentLengthTicks; } - /// <summary> - /// Upload an external subtitle file. - /// </summary> - /// <param name="itemId">The item the subtitle belongs to.</param> - /// <param name="body">The request body.</param> - /// <response code="204">Subtitle uploaded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Videos/{itemId}/Subtitles")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> UploadSubtitle( - [FromRoute, Required] Guid itemId, - [FromBody, Required] UploadSubtitleDto body) - { - var video = (Video)_libraryManager.GetItemById(itemId); - var data = Convert.FromBase64String(body.Data); - var memoryStream = new MemoryStream(data, 0, data.Length, false, true); - await using (memoryStream.ConfigureAwait(false)) - { - await _subtitleManager.UploadSubtitle( - video, - new SubtitleResponse - { - Format = body.Format, - Language = body.Language, - IsForced = body.IsForced, - Stream = memoryStream - }).ConfigureAwait(false); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - - return NoContent(); - } - } + builder.AppendLine("#EXT-X-ENDLIST"); + return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } - /// <summary> - /// Encodes a subtitle in the specified format. - /// </summary> - /// <param name="id">The media id.</param> - /// <param name="mediaSourceId">The source media id.</param> - /// <param name="index">The subtitle index.</param> - /// <param name="format">The format to convert to.</param> - /// <param name="startPositionTicks">The start position in ticks.</param> - /// <param name="endPositionTicks">The end position in ticks.</param> - /// <param name="copyTimestamps">Whether to copy the timestamps.</param> - /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> - private Task<Stream> EncodeSubtitles( - Guid id, - string? mediaSourceId, - int index, - string format, - long startPositionTicks, - long? endPositionTicks, - bool copyTimestamps) + /// <summary> + /// Upload an external subtitle file. + /// </summary> + /// <param name="itemId">The item the subtitle belongs to.</param> + /// <param name="body">The request body.</param> + /// <response code="204">Subtitle uploaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Videos/{itemId}/Subtitles")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UploadSubtitle( + [FromRoute, Required] Guid itemId, + [FromBody, Required] UploadSubtitleDto body) + { + var video = (Video)_libraryManager.GetItemById(itemId); + var data = Convert.FromBase64String(body.Data); + var memoryStream = new MemoryStream(data, 0, data.Length, false, true); + await using (memoryStream.ConfigureAwait(false)) { - var item = _libraryManager.GetItemById(id); + await _subtitleManager.UploadSubtitle( + video, + new SubtitleResponse + { + Format = body.Format, + Language = body.Language, + IsForced = body.IsForced, + Stream = memoryStream + }).ConfigureAwait(false); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - return _subtitleEncoder.GetSubtitles( - item, - mediaSourceId, - index, - format, - startPositionTicks, - endPositionTicks ?? 0, - copyTimestamps, - CancellationToken.None); + return NoContent(); } + } - /// <summary> - /// Gets a list of available fallback font files. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns> - [HttpGet("FallbackFont/Fonts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<FontFile> GetFallbackFontList() - { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var fallbackFontPath = encodingOptions.FallbackFontPath; + /// <summary> + /// Encodes a subtitle in the specified format. + /// </summary> + /// <param name="id">The media id.</param> + /// <param name="mediaSourceId">The source media id.</param> + /// <param name="index">The subtitle index.</param> + /// <param name="format">The format to convert to.</param> + /// <param name="startPositionTicks">The start position in ticks.</param> + /// <param name="endPositionTicks">The end position in ticks.</param> + /// <param name="copyTimestamps">Whether to copy the timestamps.</param> + /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> + private Task<Stream> EncodeSubtitles( + Guid id, + string? mediaSourceId, + int index, + string format, + long startPositionTicks, + long? endPositionTicks, + bool copyTimestamps) + { + var item = _libraryManager.GetItemById(id); + + return _subtitleEncoder.GetSubtitles( + item, + mediaSourceId, + index, + format, + startPositionTicks, + endPositionTicks ?? 0, + copyTimestamps, + CancellationToken.None); + } + + /// <summary> + /// Gets a list of available fallback font files. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns> + [HttpGet("FallbackFont/Fonts")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FontFile> GetFallbackFontList() + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; - if (!string.IsNullOrEmpty(fallbackFontPath)) + if (!string.IsNullOrEmpty(fallbackFontPath)) + { + var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false); + var fontFiles = files + .Select(i => new FontFile + { + Name = i.Name, + Size = i.Length, + DateCreated = _fileSystem.GetCreationTimeUtc(i), + DateModified = _fileSystem.GetLastWriteTimeUtc(i) + }) + .OrderBy(i => i.Size) + .ThenBy(i => i.Name) + .ThenByDescending(i => i.DateModified) + .ThenByDescending(i => i.DateCreated); + // max total size 20M + const int MaxSize = 20971520; + var sizeCounter = 0L; + foreach (var fontFile in fontFiles) { - var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false); - var fontFiles = files - .Select(i => new FontFile - { - Name = i.Name, - Size = i.Length, - DateCreated = _fileSystem.GetCreationTimeUtc(i), - DateModified = _fileSystem.GetLastWriteTimeUtc(i) - }) - .OrderBy(i => i.Size) - .ThenBy(i => i.Name) - .ThenByDescending(i => i.DateModified) - .ThenByDescending(i => i.DateCreated); - // max total size 20M - const int MaxSize = 20971520; - var sizeCounter = 0L; - foreach (var fontFile in fontFiles) + sizeCounter += fontFile.Size; + if (sizeCounter >= MaxSize) { - sizeCounter += fontFile.Size; - if (sizeCounter >= MaxSize) - { - _logger.LogWarning("Some fonts will not be sent due to size limitations"); - yield break; - } - - yield return fontFile; + _logger.LogWarning("Some fonts will not be sent due to size limitations"); + yield break; } - } - else - { - _logger.LogWarning("The path of fallback font folder has not been set"); - encodingOptions.EnableFallbackFont = false; + + yield return fontFile; } } - - /// <summary> - /// Gets a fallback font file. - /// </summary> - /// <param name="name">The name of the fallback font file to get.</param> - /// <response code="200">Fallback font file retrieved.</response> - /// <returns>The fallback font file.</returns> - [HttpGet("FallbackFont/Fonts/{name}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("font/*")] - public ActionResult GetFallbackFont([FromRoute, Required] string name) + else { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var fallbackFontPath = encodingOptions.FallbackFontPath; + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + } - if (!string.IsNullOrEmpty(fallbackFontPath)) - { - var fontFile = _fileSystem.GetFiles(fallbackFontPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); - var fileSize = fontFile?.Length; + /// <summary> + /// Gets a fallback font file. + /// </summary> + /// <param name="name">The name of the fallback font file to get.</param> + /// <response code="200">Fallback font file retrieved.</response> + /// <returns>The fallback font file.</returns> + [HttpGet("FallbackFont/Fonts/{name}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("font/*")] + public ActionResult GetFallbackFont([FromRoute, Required] string name) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; - if (fontFile is not null && fileSize is not null && fileSize > 0) - { - _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); - return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); - } - else - { - _logger.LogWarning("The selected font is null or empty"); - } - } - else + if (!string.IsNullOrEmpty(fallbackFontPath)) + { + var fontFile = _fileSystem.GetFiles(fallbackFontPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var fileSize = fontFile?.Length; + + if (fontFile is not null && fileSize is not null && fileSize > 0) { - _logger.LogWarning("The path of fallback font folder has not been set"); - encodingOptions.EnableFallbackFont = false; + _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); + return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); } - // returning HTTP 204 will break the SubtitlesOctopus - return Ok(); + _logger.LogWarning("The selected font is null or empty"); } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + + // returning HTTP 204 will break the SubtitlesOctopus + return Ok(); } } diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index 1cf528153f..5b808f257c 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -13,80 +12,79 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The suggestions controller. +/// </summary> +[Route("")] +[Authorize] +public class SuggestionsController : BaseJellyfinApiController { + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + /// <summary> - /// The suggestions controller. + /// Initializes a new instance of the <see cref="SuggestionsController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class SuggestionsController : BaseJellyfinApiController + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public SuggestionsController( + IDtoService dtoService, + IUserManager userManager, + ILibraryManager libraryManager) { - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; + _dtoService = dtoService; + _userManager = userManager; + _libraryManager = libraryManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="SuggestionsController"/> class. - /// </summary> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public SuggestionsController( - IDtoService dtoService, - IUserManager userManager, - ILibraryManager libraryManager) - { - _dtoService = dtoService; - _userManager = userManager; - _libraryManager = libraryManager; - } + /// <summary> + /// Gets suggestions. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media types.</param> + /// <param name="type">The type.</param> + /// <param name="startIndex">Optional. The start index.</param> + /// <param name="limit">Optional. The limit.</param> + /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param> + /// <response code="200">Suggestions returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns> + [HttpGet("Users/{userId}/Suggestions")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( + [FromRoute, Required] Guid userId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool enableTotalRecordCount = false) + { + var user = userId.Equals(default) + ? null + : _userManager.GetUserById(userId); - /// <summary> - /// Gets suggestions. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="mediaType">The media types.</param> - /// <param name="type">The type.</param> - /// <param name="startIndex">Optional. The start index.</param> - /// <param name="limit">Optional. The limit.</param> - /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param> - /// <response code="200">Suggestions returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns> - [HttpGet("Users/{userId}/Suggestions")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( - [FromRoute, Required] Guid userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool enableTotalRecordCount = false) + var dtoOptions = new DtoOptions().AddClientFields(User); + var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { - var user = userId.Equals(default) - ? null - : _userManager.GetUserById(userId); - - var dtoOptions = new DtoOptions().AddClientFields(User); - var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - MediaTypes = mediaType, - IncludeItemTypes = type, - IsVirtualItem = false, - StartIndex = startIndex, - Limit = limit, - DtoOptions = dtoOptions, - EnableTotalRecordCount = enableTotalRecordCount, - Recursive = true - }); + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, + MediaTypes = mediaType, + IncludeItemTypes = type, + IsVirtualItem = false, + StartIndex = startIndex, + Limit = limit, + DtoOptions = dtoOptions, + EnableTotalRecordCount = enableTotalRecordCount, + Recursive = true + }); - var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); + var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); - return new QueryResult<BaseItemDto>( - startIndex, - result.TotalRecordCount, - dtoList); - } + return new QueryResult<BaseItemDto>( + startIndex, + result.TotalRecordCount, + dtoList); } } diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index 99347246e0..23abba7dc7 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -16,409 +16,408 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The sync play controller. +/// </summary> +[Authorize(Policy = Policies.SyncPlayHasAccess)] +public class SyncPlayController : BaseJellyfinApiController { + private readonly ISessionManager _sessionManager; + private readonly ISyncPlayManager _syncPlayManager; + private readonly IUserManager _userManager; + /// <summary> - /// The sync play controller. + /// Initializes a new instance of the <see cref="SyncPlayController"/> class. /// </summary> - [Authorize(Policy = Policies.SyncPlayHasAccess)] - public class SyncPlayController : BaseJellyfinApiController + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public SyncPlayController( + ISessionManager sessionManager, + ISyncPlayManager syncPlayManager, + IUserManager userManager) { - private readonly ISessionManager _sessionManager; - private readonly ISyncPlayManager _syncPlayManager; - private readonly IUserManager _userManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SyncPlayController"/> class. - /// </summary> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager, - IUserManager userManager) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - _userManager = userManager; - } + _sessionManager = sessionManager; + _syncPlayManager = syncPlayManager; + _userManager = userManager; + } - /// <summary> - /// Create a new SyncPlay group. - /// </summary> - /// <param name="requestData">The settings of the new group.</param> - /// <response code="204">New group created.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("New")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayCreateGroup)] - public async Task<ActionResult> SyncPlayCreateGroup( - [FromBody, Required] NewGroupRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new NewGroupRequest(requestData.GroupName); - _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Create a new SyncPlay group. + /// </summary> + /// <param name="requestData">The settings of the new group.</param> + /// <response code="204">New group created.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("New")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayCreateGroup)] + public async Task<ActionResult> SyncPlayCreateGroup( + [FromBody, Required] NewGroupRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new NewGroupRequest(requestData.GroupName); + _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Join an existing SyncPlay group. - /// </summary> - /// <param name="requestData">The group to join.</param> - /// <response code="204">Group join successful.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Join")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public async Task<ActionResult> SyncPlayJoinGroup( - [FromBody, Required] JoinGroupRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); - _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Join an existing SyncPlay group. + /// </summary> + /// <param name="requestData">The group to join.</param> + /// <response code="204">Group join successful.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Join")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public async Task<ActionResult> SyncPlayJoinGroup( + [FromBody, Required] JoinGroupRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); + _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Leave the joined SyncPlay group. - /// </summary> - /// <response code="204">Group leave successful.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Leave")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayLeaveGroup() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new LeaveGroupRequest(); - _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Leave the joined SyncPlay group. + /// </summary> + /// <response code="204">Group leave successful.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Leave")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayLeaveGroup() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new LeaveGroupRequest(); + _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Gets all SyncPlay groups. - /// </summary> - /// <response code="200">Groups returned.</response> - /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> - [HttpGet("List")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new ListGroupsRequest(); - return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable()); - } + /// <summary> + /// Gets all SyncPlay groups. + /// </summary> + /// <response code="200">Groups returned.</response> + /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new ListGroupsRequest(); + return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable()); + } - /// <summary> - /// Request to set new playlist in SyncPlay group. - /// </summary> - /// <param name="requestData">The new playlist to play in the group.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetNewQueue")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetNewQueue( - [FromBody, Required] PlayRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PlayGroupRequest( - requestData.PlayingQueue, - requestData.PlayingItemPosition, - requestData.StartPositionTicks); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to set new playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The new playlist to play in the group.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetNewQueue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetNewQueue( + [FromBody, Required] PlayRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PlayGroupRequest( + requestData.PlayingQueue, + requestData.PlayingItemPosition, + requestData.StartPositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to change playlist item in SyncPlay group. - /// </summary> - /// <param name="requestData">The new item to play.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetPlaylistItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetPlaylistItem( - [FromBody, Required] SetPlaylistItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to change playlist item in SyncPlay group. + /// </summary> + /// <param name="requestData">The new item to play.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetPlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetPlaylistItem( + [FromBody, Required] SetPlaylistItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to remove items from the playlist in SyncPlay group. - /// </summary> - /// <param name="requestData">The items to remove.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("RemoveFromPlaylist")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayRemoveFromPlaylist( - [FromBody, Required] RemoveFromPlaylistRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to remove items from the playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The items to remove.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("RemoveFromPlaylist")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayRemoveFromPlaylist( + [FromBody, Required] RemoveFromPlaylistRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to move an item in the playlist in SyncPlay group. - /// </summary> - /// <param name="requestData">The new position for the item.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("MovePlaylistItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayMovePlaylistItem( - [FromBody, Required] MovePlaylistItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to move an item in the playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The new position for the item.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("MovePlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayMovePlaylistItem( + [FromBody, Required] MovePlaylistItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to queue items to the playlist of a SyncPlay group. - /// </summary> - /// <param name="requestData">The items to add.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Queue")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayQueue( - [FromBody, Required] QueueRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to queue items to the playlist of a SyncPlay group. + /// </summary> + /// <param name="requestData">The items to add.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Queue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayQueue( + [FromBody, Required] QueueRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request unpause in SyncPlay group. - /// </summary> - /// <response code="204">Unpause update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Unpause")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayUnpause() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new UnpauseGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request unpause in SyncPlay group. + /// </summary> + /// <response code="204">Unpause update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Unpause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayUnpause() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new UnpauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request pause in SyncPlay group. - /// </summary> - /// <response code="204">Pause update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Pause")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayPause() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PauseGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request pause in SyncPlay group. + /// </summary> + /// <response code="204">Pause update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Pause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayPause() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request stop in SyncPlay group. - /// </summary> - /// <response code="204">Stop update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Stop")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayStop() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new StopGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request stop in SyncPlay group. + /// </summary> + /// <response code="204">Stop update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayStop() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new StopGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request seek in SyncPlay group. - /// </summary> - /// <param name="requestData">The new playback position.</param> - /// <response code="204">Seek update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Seek")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySeek( - [FromBody, Required] SeekRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request seek in SyncPlay group. + /// </summary> + /// <param name="requestData">The new playback position.</param> + /// <response code="204">Seek update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Seek")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySeek( + [FromBody, Required] SeekRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Notify SyncPlay group that member is buffering. - /// </summary> - /// <param name="requestData">The player status.</param> - /// <response code="204">Group state update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Buffering")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayBuffering( - [FromBody, Required] BufferRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new BufferGroupRequest( - requestData.When, - requestData.PositionTicks, - requestData.IsPlaying, - requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Notify SyncPlay group that member is buffering. + /// </summary> + /// <param name="requestData">The player status.</param> + /// <response code="204">Group state update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Buffering")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayBuffering( + [FromBody, Required] BufferRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new BufferGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Notify SyncPlay group that member is ready for playback. - /// </summary> - /// <param name="requestData">The player status.</param> - /// <response code="204">Group state update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Ready")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayReady( - [FromBody, Required] ReadyRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new ReadyGroupRequest( - requestData.When, - requestData.PositionTicks, - requestData.IsPlaying, - requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Notify SyncPlay group that member is ready for playback. + /// </summary> + /// <param name="requestData">The player status.</param> + /// <response code="204">Group state update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Ready")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayReady( + [FromBody, Required] ReadyRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new ReadyGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request SyncPlay group to ignore member during group-wait. - /// </summary> - /// <param name="requestData">The settings to set.</param> - /// <response code="204">Member state updated.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetIgnoreWait")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetIgnoreWait( - [FromBody, Required] IgnoreWaitRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request SyncPlay group to ignore member during group-wait. + /// </summary> + /// <param name="requestData">The settings to set.</param> + /// <response code="204">Member state updated.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetIgnoreWait")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetIgnoreWait( + [FromBody, Required] IgnoreWaitRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request next item in SyncPlay group. - /// </summary> - /// <param name="requestData">The current item information.</param> - /// <response code="204">Next item update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("NextItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayNextItem( - [FromBody, Required] NextItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request next item in SyncPlay group. + /// </summary> + /// <param name="requestData">The current item information.</param> + /// <response code="204">Next item update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("NextItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayNextItem( + [FromBody, Required] NextItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request previous item in SyncPlay group. - /// </summary> - /// <param name="requestData">The current item information.</param> - /// <response code="204">Previous item update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("PreviousItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayPreviousItem( - [FromBody, Required] PreviousItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request previous item in SyncPlay group. + /// </summary> + /// <param name="requestData">The current item information.</param> + /// <response code="204">Previous item update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("PreviousItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayPreviousItem( + [FromBody, Required] PreviousItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to set repeat mode in SyncPlay group. - /// </summary> - /// <param name="requestData">The new repeat mode.</param> - /// <response code="204">Play queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetRepeatMode")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetRepeatMode( - [FromBody, Required] SetRepeatModeRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to set repeat mode in SyncPlay group. + /// </summary> + /// <param name="requestData">The new repeat mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetRepeatMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetRepeatMode( + [FromBody, Required] SetRepeatModeRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to set shuffle mode in SyncPlay group. - /// </summary> - /// <param name="requestData">The new shuffle mode.</param> - /// <response code="204">Play queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetShuffleMode")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetShuffleMode( - [FromBody, Required] SetShuffleModeRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to set shuffle mode in SyncPlay group. + /// </summary> + /// <param name="requestData">The new shuffle mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetShuffleMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetShuffleMode( + [FromBody, Required] SetShuffleModeRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Update session ping. - /// </summary> - /// <param name="requestData">The new ping.</param> - /// <response code="204">Ping updated.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Ping")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SyncPlayPing( - [FromBody, Required] PingRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PingGroupRequest(requestData.Ping); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Update session ping. + /// </summary> + /// <param name="requestData">The new ping.</param> + /// <response code="204">Ping updated.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SyncPlayPing( + [FromBody, Required] PingRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PingGroupRequest(requestData.Ping); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 2d594293e0..9ed69f4205 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -20,204 +20,215 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The system controller. +/// </summary> +public class SystemController : BaseJellyfinApiController { + private readonly IServerApplicationHost _appHost; + private readonly IApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private readonly INetworkManager _network; + private readonly ILogger<SystemController> _logger; + /// <summary> - /// The system controller. + /// Initializes a new instance of the <see cref="SystemController"/> class. /// </summary> - public class SystemController : BaseJellyfinApiController + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param> + public SystemController( + IServerConfigurationManager serverConfigurationManager, + IServerApplicationHost appHost, + IFileSystem fileSystem, + INetworkManager network, + ILogger<SystemController> logger) { - private readonly IServerApplicationHost _appHost; - private readonly IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private readonly INetworkManager _network; - private readonly ILogger<SystemController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="SystemController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> - /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> - /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param> - /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param> - public SystemController( - IServerConfigurationManager serverConfigurationManager, - IServerApplicationHost appHost, - IFileSystem fileSystem, - INetworkManager network, - ILogger<SystemController> logger) - { - _appPaths = serverConfigurationManager.ApplicationPaths; - _appHost = appHost; - _fileSystem = fileSystem; - _network = network; - _logger = logger; - } + _appPaths = serverConfigurationManager.ApplicationPaths; + _appHost = appHost; + _fileSystem = fileSystem; + _network = network; + _logger = logger; + } - /// <summary> - /// Gets information about the server. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns> - [HttpGet("Info")] - [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<SystemInfo> GetSystemInfo() - { - return _appHost.GetSystemInfo(Request); - } + /// <summary> + /// Gets information about the server. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <response code="403">User does not have permission to retrieve information.</response> + /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns> + [HttpGet("Info")] + [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult<SystemInfo> GetSystemInfo() + { + return _appHost.GetSystemInfo(Request); + } - /// <summary> - /// Gets public information about the server. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns> - [HttpGet("Info/Public")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<PublicSystemInfo> GetPublicSystemInfo() - { - return _appHost.GetPublicSystemInfo(Request); - } + /// <summary> + /// Gets public information about the server. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns> + [HttpGet("Info/Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<PublicSystemInfo> GetPublicSystemInfo() + { + return _appHost.GetPublicSystemInfo(Request); + } - /// <summary> - /// Pings the system. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>The server name.</returns> - [HttpGet("Ping", Name = "GetPingSystem")] - [HttpPost("Ping", Name = "PostPingSystem")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<string> PingSystem() - { - return _appHost.Name; - } + /// <summary> + /// Pings the system. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>The server name.</returns> + [HttpGet("Ping", Name = "GetPingSystem")] + [HttpPost("Ping", Name = "PostPingSystem")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<string> PingSystem() + { + return _appHost.Name; + } - /// <summary> - /// Restarts the application. - /// </summary> - /// <response code="204">Server restarted.</response> - /// <returns>No content. Server restarted.</returns> - [HttpPost("Restart")] - [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RestartApplication() + /// <summary> + /// Restarts the application. + /// </summary> + /// <response code="204">Server restarted.</response> + /// <response code="403">User does not have permission to restart server.</response> + /// <returns>No content. Server restarted.</returns> + [HttpPost("Restart")] + [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult RestartApplication() + { + Task.Run(async () => { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - _appHost.Restart(); - }); - return NoContent(); - } + await Task.Delay(100).ConfigureAwait(false); + _appHost.Restart(); + }); + return NoContent(); + } - /// <summary> - /// Shuts down the application. - /// </summary> - /// <response code="204">Server shut down.</response> - /// <returns>No content. Server shut down.</returns> - [HttpPost("Shutdown")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult ShutdownApplication() + /// <summary> + /// Shuts down the application. + /// </summary> + /// <response code="204">Server shut down.</response> + /// <response code="403">User does not have permission to shutdown server.</response> + /// <returns>No content. Server shut down.</returns> + [HttpPost("Shutdown")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult ShutdownApplication() + { + Task.Run(async () => { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - await _appHost.Shutdown().ConfigureAwait(false); - }); - return NoContent(); - } + await Task.Delay(100).ConfigureAwait(false); + await _appHost.Shutdown().ConfigureAwait(false); + }); + return NoContent(); + } + + /// <summary> + /// Gets a list of available server log files. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <response code="403">User does not have permission to get server logs.</response> + /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns> + [HttpGet("Logs")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult<LogFile[]> GetServerLogs() + { + IEnumerable<FileSystemMetadata> files; - /// <summary> - /// Gets a list of available server log files. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns> - [HttpGet("Logs")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<LogFile[]> GetServerLogs() + try { - IEnumerable<FileSystemMetadata> files; - - try - { - files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error getting logs"); - files = Enumerable.Empty<FileSystemMetadata>(); - } - - var result = files.Select(i => new LogFile - { - DateCreated = _fileSystem.GetCreationTimeUtc(i), - DateModified = _fileSystem.GetLastWriteTimeUtc(i), - Name = i.Name, - Size = i.Length - }) - .OrderByDescending(i => i.DateModified) - .ThenByDescending(i => i.DateCreated) - .ThenBy(i => i.Name) - .ToArray(); - - return result; + files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); } - - /// <summary> - /// Gets information about the request endpoint. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns> - [HttpGet("Endpoint")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<EndPointInfo> GetEndpointInfo() + catch (IOException ex) { - return new EndPointInfo - { - IsLocal = HttpContext.IsLocal(), - IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()) - }; + _logger.LogError(ex, "Error getting logs"); + files = Enumerable.Empty<FileSystemMetadata>(); } - /// <summary> - /// Gets a log file. - /// </summary> - /// <param name="name">The name of the log file to get.</param> - /// <response code="200">Log file retrieved.</response> - /// <returns>The log file.</returns> - [HttpGet("Logs/Log")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Text.Plain)] - public ActionResult GetLogFile([FromQuery, Required] string name) + var result = files.Select(i => new LogFile { - var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + DateCreated = _fileSystem.GetCreationTimeUtc(i), + DateModified = _fileSystem.GetLastWriteTimeUtc(i), + Name = i.Name, + Size = i.Length + }) + .OrderByDescending(i => i.DateModified) + .ThenByDescending(i => i.DateCreated) + .ThenBy(i => i.Name) + .ToArray(); - // For older files, assume fully static - var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; - FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - return File(stream, "text/plain; charset=utf-8"); - } + return result; + } - /// <summary> - /// Gets wake on lan information. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns> - [HttpGet("WakeOnLanInfo")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo() + /// <summary> + /// Gets information about the request endpoint. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <response code="403">User does not have permission to get endpoint information.</response> + /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns> + [HttpGet("Endpoint")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult<EndPointInfo> GetEndpointInfo() + { + return new EndPointInfo { - var result = _network.GetMacAddresses() - .Select(i => new WakeOnLanInfo(i)); - return Ok(result); - } + IsLocal = HttpContext.IsLocal(), + IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()) + }; + } + + /// <summary> + /// Gets a log file. + /// </summary> + /// <param name="name">The name of the log file to get.</param> + /// <response code="200">Log file retrieved.</response> + /// <response code="403">User does not have permission to get log files.</response> + /// <returns>The log file.</returns> + [HttpGet("Logs/Log")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesFile(MediaTypeNames.Text.Plain)] + public ActionResult GetLogFile([FromQuery, Required] string name) + { + var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + + // For older files, assume fully static + var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; + FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + return File(stream, "text/plain; charset=utf-8"); + } + + /// <summary> + /// Gets wake on lan information. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns> + [HttpGet("WakeOnLanInfo")] + [Authorize] + [Obsolete("This endpoint is obsolete.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo() + { + var result = _network.GetMacAddresses() + .Select(i => new WakeOnLanInfo(i)); + return Ok(result); } } diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index e7c5a71257..d7304cf426 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -3,32 +3,31 @@ using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The time sync controller. +/// </summary> +[Route("")] +public class TimeSyncController : BaseJellyfinApiController { /// <summary> - /// The time sync controller. + /// Gets the current UTC time. /// </summary> - [Route("")] - public class TimeSyncController : BaseJellyfinApiController + /// <response code="200">Time returned.</response> + /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns> + [HttpGet("GetUtcTime")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + public ActionResult<UtcTimeResponse> GetUtcTime() { - /// <summary> - /// Gets the current UTC time. - /// </summary> - /// <response code="200">Time returned.</response> - /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns> - [HttpGet("GetUtcTime")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public ActionResult<UtcTimeResponse> GetUtcTime() - { - // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow; + // Important to keep the following line at the beginning + var requestReceptionTime = DateTime.UtcNow; - // Important to keep the following line at the end - var responseTransmissionTime = DateTime.UtcNow; + // Important to keep the following line at the end + var responseTransmissionTime = DateTime.UtcNow; - // Implementing NTP on such a high level results in this useless - // information being sent. On the other hand it enables future additions. - return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); - } + // Implementing NTP on such a high level results in this useless + // information being sent. On the other hand it enables future additions. + return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); } } diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 53a839e431..b5b6406206 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,6 +1,4 @@ using System; -using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; @@ -10,290 +8,289 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The trailers controller. +/// </summary> +[Authorize] +public class TrailersController : BaseJellyfinApiController { + private readonly ItemsController _itemsController; + /// <summary> - /// The trailers controller. + /// Initializes a new instance of the <see cref="TrailersController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class TrailersController : BaseJellyfinApiController + /// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param> + public TrailersController(ItemsController itemsController) { - private readonly ItemsController _itemsController; - - /// <summary> - /// Initializes a new instance of the <see cref="TrailersController"/> class. - /// </summary> - /// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param> - public TrailersController(ItemsController itemsController) - { - _itemsController = itemsController; - } + _itemsController = itemsController; + } - /// <summary> - /// Finds movies and trailers similar to a given trailer. - /// </summary> - /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> - /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> - /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> - /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> - /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> - /// <param name="hasTrailer">Optional filter by items with trailers.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="parentIndexNumber">Optional filter by parent index number.</param> - /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> - /// <param name="isHd">Optional filter by items that are HD or not.</param> - /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> - /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> - /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> - /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> - /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> - /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> - /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> - /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> - /// <param name="isMovie">Optional filter for live tv movies.</param> - /// <param name="isSeries">Optional filter for live tv series.</param> - /// <param name="isNews">Optional filter for live tv news.</param> - /// <param name="isKids">Optional filter for live tv kids.</param> - /// <param name="isSports">Optional filter for live tv sports.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> - /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> - /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> - /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> - /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> - /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> - /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="isLocked">Optional filter by items that are locked.</param> - /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> - /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> - /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> - /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> - /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> - /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> - /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> - /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <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="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)] - public ActionResult<QueryResult<BaseItemDto>> GetTrailers( - [FromQuery] Guid? userId, - [FromQuery] string? maxOfficialRating, - [FromQuery] bool? hasThemeSong, - [FromQuery] bool? hasThemeVideo, - [FromQuery] bool? hasSubtitles, - [FromQuery] bool? hasSpecialFeature, - [FromQuery] bool? hasTrailer, - [FromQuery] Guid? adjacentTo, - [FromQuery] int? parentIndexNumber, - [FromQuery] bool? hasParentalRating, - [FromQuery] bool? isHd, - [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, - [FromQuery] bool? isMissing, - [FromQuery] bool? isUnaired, - [FromQuery] double? minCommunityRating, - [FromQuery] double? minCriticRating, - [FromQuery] DateTime? minPremiereDate, - [FromQuery] DateTime? minDateLastSaved, - [FromQuery] DateTime? minDateLastSavedForUser, - [FromQuery] DateTime? maxPremiereDate, - [FromQuery] bool? hasOverview, - [FromQuery] bool? hasImdbId, - [FromQuery] bool? hasTmdbId, - [FromQuery] bool? hasTvdbId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? recursive, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, - [FromQuery] string? minOfficialRating, - [FromQuery] bool? isLocked, - [FromQuery] bool? isPlaceHolder, - [FromQuery] bool? hasOfficialRating, - [FromQuery] bool? collapseBoxSetItems, - [FromQuery] int? minWidth, - [FromQuery] int? minHeight, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) - { - var includeItemTypes = new[] { BaseItemKind.Trailer }; + /// <summary> + /// Finds movies and trailers similar to a given trailer. + /// </summary> + /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> + /// <param name="isMovie">Optional filter for live tv movies.</param> + /// <param name="isSeries">Optional filter for live tv series.</param> + /// <param name="isNews">Optional filter for live tv news.</param> + /// <param name="isKids">Optional filter for live tv kids.</param> + /// <param name="isSports">Optional filter for live tv sports.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</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.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <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="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)] + public ActionResult<QueryResult<BaseItemDto>> GetTrailers( + [FromQuery] Guid? userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] Guid? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? isPlayed, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) + { + var includeItemTypes = new[] { BaseItemKind.Trailer }; - return _itemsController - .GetItems( - userId, - maxOfficialRating, - hasThemeSong, - hasThemeVideo, - hasSubtitles, - hasSpecialFeature, - hasTrailer, - adjacentTo, - parentIndexNumber, - hasParentalRating, - isHd, - is4K, - locationTypes, - excludeLocationTypes, - isMissing, - isUnaired, - minCommunityRating, - minCriticRating, - minPremiereDate, - minDateLastSaved, - minDateLastSavedForUser, - maxPremiereDate, - hasOverview, - hasImdbId, - hasTmdbId, - hasTvdbId, - isMovie, - isSeries, - isNews, - isKids, - isSports, - excludeItemIds, - startIndex, - limit, - recursive, - searchTerm, - sortOrder, - parentId, - fields, - excludeItemTypes, - includeItemTypes, - filters, - isFavorite, - mediaTypes, - imageTypes, - sortBy, - isPlayed, - genres, - officialRatings, - tags, - years, - enableUserData, - imageTypeLimit, - enableImageTypes, - person, - personIds, - personTypes, - studios, - artists, - excludeArtistIds, - artistIds, - albumArtistIds, - contributingArtistIds, - albums, - albumIds, - ids, - videoTypes, - minOfficialRating, - isLocked, - isPlaceHolder, - hasOfficialRating, - collapseBoxSetItems, - minWidth, - minHeight, - maxWidth, - maxHeight, - is3D, - seriesStatus, - nameStartsWithOrGreater, - nameStartsWith, - nameLessThan, - studioIds, - genreIds, - enableTotalRecordCount, - enableImages); - } + return _itemsController + .GetItems( + userId, + maxOfficialRating, + hasThemeSong, + hasThemeVideo, + hasSubtitles, + hasSpecialFeature, + hasTrailer, + adjacentTo, + parentIndexNumber, + hasParentalRating, + isHd, + is4K, + locationTypes, + excludeLocationTypes, + isMissing, + isUnaired, + minCommunityRating, + minCriticRating, + minPremiereDate, + minDateLastSaved, + minDateLastSavedForUser, + maxPremiereDate, + hasOverview, + hasImdbId, + hasTmdbId, + hasTvdbId, + isMovie, + isSeries, + isNews, + isKids, + isSports, + excludeItemIds, + startIndex, + limit, + recursive, + searchTerm, + sortOrder, + parentId, + fields, + excludeItemTypes, + includeItemTypes, + filters, + isFavorite, + mediaTypes, + imageTypes, + sortBy, + isPlayed, + genres, + officialRatings, + tags, + years, + enableUserData, + imageTypeLimit, + enableImageTypes, + person, + personIds, + personTypes, + studios, + artists, + excludeArtistIds, + artistIds, + albumArtistIds, + contributingArtistIds, + albums, + albumIds, + ids, + videoTypes, + minOfficialRating, + isLocked, + isPlaceHolder, + hasOfficialRating, + collapseBoxSetItems, + minWidth, + minHeight, + maxWidth, + maxHeight, + is3D, + seriesStatus, + nameStartsWithOrGreater, + nameStartsWith, + nameLessThan, + studioIds, + genreIds, + enableTotalRecordCount, + enableImages); } } diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 7f4f4d0776..7d23281f2c 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -19,366 +19,369 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The tv shows controller. +/// </summary> +[Route("Shows")] +[Authorize] +public class TvShowsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly ITVSeriesManager _tvSeriesManager; + /// <summary> - /// The tv shows controller. + /// Initializes a new instance of the <see cref="TvShowsController"/> class. /// </summary> - [Route("Shows")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class TvShowsController : BaseJellyfinApiController + /// <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="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param> + public TvShowsController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + ITVSeriesManager tvSeriesManager) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly ITVSeriesManager _tvSeriesManager; - - /// <summary> - /// Initializes a new instance of the <see cref="TvShowsController"/> 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="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param> - public TvShowsController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - ITVSeriesManager tvSeriesManager) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _tvSeriesManager = tvSeriesManager; - } - - /// <summary> - /// Gets a list of next up episodes. - /// </summary> - /// <param name="userId">The user id of the user to get the next up episodes for.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</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> - /// <param name="seriesId">Optional. Filter by series id.</param> - /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> - /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> - /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> - /// <param name="enableRewatching">Whether to include watched episode in next up results.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> - [HttpGet("NextUp")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetNextUp( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] Guid? seriesId, - [FromQuery] Guid? parentId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] DateTime? nextUpDateCutoff, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool disableFirstEpisode = false, - [FromQuery] bool enableRewatching = false) - { - var options = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - var result = _tvSeriesManager.GetNextUp( - new NextUpQuery - { - Limit = limit, - ParentId = parentId, - SeriesId = seriesId, - StartIndex = startIndex, - UserId = userId ?? Guid.Empty, - EnableTotalRecordCount = enableTotalRecordCount, - DisableFirstEpisode = disableFirstEpisode, - NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, - EnableRewatching = enableRewatching - }, - options); - - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user); - - return new QueryResult<BaseItemDto>( - startIndex, - result.TotalRecordCount, - returnItems); - } - - /// <summary> - /// Gets a list of upcoming episodes. - /// </summary> - /// <param name="userId">The user id of the user to get the upcoming episodes for.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</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> - /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> - [HttpGet("Upcoming")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] Guid? parentId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1); - - var parentIdGuid = parentId ?? Guid.Empty; + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _tvSeriesManager = tvSeriesManager; + } - var options = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + /// <summary> + /// Gets a list of next up episodes. + /// </summary> + /// <param name="userId">The user id of the user to get the next up episodes for.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</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> + /// <param name="seriesId">Optional. Filter by series id.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> + /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> + /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> + /// <param name="enableRewatching">Whether to include watched episode in next up results.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> + [HttpGet("NextUp")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetNextUp( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] Guid? seriesId, + [FromQuery] Guid? parentId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] DateTime? nextUpDateCutoff, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool disableFirstEpisode = false, + [FromQuery] bool enableRewatching = false) + { + userId = RequestHelpers.GetUserId(User, userId); + var options = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) + var result = _tvSeriesManager.GetNextUp( + new NextUpQuery { - IncludeItemTypes = new[] { BaseItemKind.Episode }, - OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, - MinPremiereDate = minPremiereDate, - StartIndex = startIndex, Limit = limit, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = options - }); + ParentId = parentId, + SeriesId = seriesId, + StartIndex = startIndex, + UserId = userId.Value, + EnableTotalRecordCount = enableTotalRecordCount, + DisableFirstEpisode = disableFirstEpisode, + NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, + EnableRewatching = enableRewatching + }, + options); + + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user); + + return new QueryResult<BaseItemDto>( + startIndex, + result.TotalRecordCount, + returnItems); + } - var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); + /// <summary> + /// Gets a list of upcoming episodes. + /// </summary> + /// <param name="userId">The user id of the user to get the upcoming episodes for.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</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> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> + [HttpGet("Upcoming")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] Guid? parentId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - return new QueryResult<BaseItemDto>( - startIndex, - itemsResult.Count, - returnItems); - } + var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1); - /// <summary> - /// Gets episodes for a tv season. - /// </summary> - /// <param name="seriesId">The series id.</param> - /// <param name="userId">The user id.</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="season">Optional filter by season number.</param> - /// <param name="seasonId">Optional. Filter by season id.</param> - /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="startItemId">Optional. Skip through the list until a given item is found.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> - [HttpGet("{seriesId}/Episodes")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( - [FromRoute, Required] Guid seriesId, - [FromQuery] Guid? userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] int? season, - [FromQuery] Guid? seasonId, - [FromQuery] bool? isMissing, - [FromQuery] Guid? adjacentTo, - [FromQuery] Guid? startItemId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] string? sortBy) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + var parentIdGuid = parentId ?? Guid.Empty; - List<BaseItem> episodes; + var options = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, + MinPremiereDate = minPremiereDate, + StartIndex = startIndex, + Limit = limit, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = options + }); + + var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); + + return new QueryResult<BaseItemDto>( + startIndex, + itemsResult.Count, + returnItems); + } - if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. - { - var item = _libraryManager.GetItemById(seasonId.Value); - if (item is not Season seasonItem) - { - return NotFound("No season exists with Id " + seasonId); - } + /// <summary> + /// Gets episodes for a tv season. + /// </summary> + /// <param name="seriesId">The series id.</param> + /// <param name="userId">The user id.</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="season">Optional filter by season number.</param> + /// <param name="seasonId">Optional. Filter by season id.</param> + /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="startItemId">Optional. Skip through the list until a given item is found.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> + [HttpGet("{seriesId}/Episodes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( + [FromRoute, Required] Guid seriesId, + [FromQuery] Guid? userId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] int? season, + [FromQuery] Guid? seasonId, + [FromQuery] bool? isMissing, + [FromQuery] Guid? adjacentTo, + [FromQuery] Guid? startItemId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] string? sortBy) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - episodes = seasonItem.GetEpisodes(user, dtoOptions); - } - else if (season.HasValue) // Season number was supplied. Get episodes by season number - { - if (_libraryManager.GetItemById(seriesId) is not Series series) - { - return NotFound("Series not found"); - } - - var seasonItem = series - .GetSeasons(user, dtoOptions) - .FirstOrDefault(i => i.IndexNumber == season.Value); - - episodes = seasonItem is null ? - new List<BaseItem>() - : ((Season)seasonItem).GetEpisodes(user, dtoOptions); - } - else // No season number or season id was supplied. Returning all episodes. - { - if (_libraryManager.GetItemById(seriesId) is not Series series) - { - return NotFound("Series not found"); - } + List<BaseItem> episodes; - episodes = series.GetEpisodes(user, dtoOptions).ToList(); - } + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - // Filter after the fact in case the ui doesn't want them - if (isMissing.HasValue) + if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. + { + var item = _libraryManager.GetItemById(seasonId.Value); + if (item is not Season seasonItem) { - var val = isMissing.Value; - episodes = episodes - .Where(i => ((Episode)i).IsMissingEpisode == val) - .ToList(); + return NotFound("No season exists with Id " + seasonId); } - if (startItemId.HasValue) + episodes = seasonItem.GetEpisodes(user, dtoOptions); + } + else if (season.HasValue) // Season number was supplied. Get episodes by season number + { + if (_libraryManager.GetItemById(seriesId) is not Series series) { - episodes = episodes - .SkipWhile(i => !startItemId.Value.Equals(i.Id)) - .ToList(); + return NotFound("Series not found"); } - // This must be the last filter - if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default)) - { - episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList(); - } + var seasonItem = series + .GetSeasons(user, dtoOptions) + .FirstOrDefault(i => i.IndexNumber == season.Value); - if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) + episodes = seasonItem is null ? + new List<BaseItem>() + : ((Season)seasonItem).GetEpisodes(user, dtoOptions); + } + else // No season number or season id was supplied. Returning all episodes. + { + if (_libraryManager.GetItemById(seriesId) is not Series series) { - episodes.Shuffle(); + return NotFound("Series not found"); } - var returnItems = episodes; + episodes = series.GetEpisodes(user, dtoOptions).ToList(); + } - if (startIndex.HasValue || limit.HasValue) - { - returnItems = ApplyPaging(episodes, startIndex, limit).ToList(); - } + // Filter after the fact in case the ui doesn't want them + if (isMissing.HasValue) + { + var val = isMissing.Value; + episodes = episodes + .Where(i => ((Episode)i).IsMissingEpisode == val) + .ToList(); + } - var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); + if (startItemId.HasValue) + { + episodes = episodes + .SkipWhile(i => !startItemId.Value.Equals(i.Id)) + .ToList(); + } - return new QueryResult<BaseItemDto>( - startIndex, - episodes.Count, - dtos); + // This must be the last filter + if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default)) + { + episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList(); } - /// <summary> - /// Gets seasons for a tv series. - /// </summary> - /// <param name="seriesId">The series id.</param> - /// <param name="userId">The user id.</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="isSpecialSeason">Optional. Filter by special season.</param> - /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> - [HttpGet("{seriesId}/Seasons")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<QueryResult<BaseItemDto>> GetSeasons( - [FromRoute, Required] Guid seriesId, - [FromQuery] Guid? userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? isSpecialSeason, - [FromQuery] bool? isMissing, - [FromQuery] Guid? adjacentTo, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData) + if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + episodes.Shuffle(); + } - if (_libraryManager.GetItemById(seriesId) is not Series series) - { - return NotFound("Series not found"); - } + var returnItems = episodes; - var seasons = series.GetItemList(new InternalItemsQuery(user) - { - IsMissing = isMissing, - IsSpecialSeason = isSpecialSeason, - AdjacentTo = adjacentTo - }); + if (startIndex.HasValue || limit.HasValue) + { + returnItems = ApplyPaging(episodes, startIndex, limit).ToList(); + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); - var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); + return new QueryResult<BaseItemDto>( + startIndex, + episodes.Count, + dtos); + } - return new QueryResult<BaseItemDto>(returnItems); + /// <summary> + /// Gets seasons for a tv series. + /// </summary> + /// <param name="seriesId">The series id.</param> + /// <param name="userId">The user id.</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="isSpecialSeason">Optional. Filter by special season.</param> + /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> + [HttpGet("{seriesId}/Seasons")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetSeasons( + [FromRoute, Required] Guid seriesId, + [FromQuery] Guid? userId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? isSpecialSeason, + [FromQuery] bool? isMissing, + [FromQuery] Guid? adjacentTo, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + if (_libraryManager.GetItemById(seriesId) is not Series series) + { + return NotFound("Series not found"); } - /// <summary> - /// Applies the paging. - /// </summary> - /// <param name="items">The items.</param> - /// <param name="startIndex">The start index.</param> - /// <param name="limit">The limit.</param> - /// <returns>IEnumerable{BaseItem}.</returns> - private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit) + var seasons = series.GetItemList(new InternalItemsQuery(user) { - // Start at - if (startIndex.HasValue) - { - items = items.Skip(startIndex.Value); - } + IsMissing = isMissing, + IsSpecialSeason = isSpecialSeason, + AdjacentTo = adjacentTo + }); - // Return limit - if (limit.HasValue) - { - items = items.Take(limit.Value); - } + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); - return items; + return new QueryResult<BaseItemDto>(returnItems); + } + + /// <summary> + /// Applies the paging. + /// </summary> + /// <param name="items">The items.</param> + /// <param name="startIndex">The start index.</param> + /// <param name="limit">The limit.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit) + { + // Start at + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value); } + + // Return limit + if (limit.HasValue) + { + items = items.Take(limit.Value); + } + + return items; } } diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index d77126a353..2e9035d24f 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -5,8 +5,6 @@ using System.Globalization; using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; -using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; @@ -20,197 +18,160 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The universal audio controller. +/// </summary> +[Route("")] +public class UniversalAudioController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly ILogger<UniversalAudioController> _logger; + private readonly MediaInfoHelper _mediaInfoHelper; + private readonly AudioHelper _audioHelper; + private readonly DynamicHlsHelper _dynamicHlsHelper; + /// <summary> - /// The universal audio controller. + /// Initializes a new instance of the <see cref="UniversalAudioController"/> class. /// </summary> - [Route("")] - public class UniversalAudioController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param> + /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param> + /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> + /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> + public UniversalAudioController( + ILibraryManager libraryManager, + ILogger<UniversalAudioController> logger, + MediaInfoHelper mediaInfoHelper, + AudioHelper audioHelper, + DynamicHlsHelper dynamicHlsHelper) { - private readonly ILibraryManager _libraryManager; - private readonly ILogger<UniversalAudioController> _logger; - private readonly MediaInfoHelper _mediaInfoHelper; - private readonly AudioHelper _audioHelper; - private readonly DynamicHlsHelper _dynamicHlsHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="UniversalAudioController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param> - /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param> - /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> - /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> - public UniversalAudioController( - ILibraryManager libraryManager, - ILogger<UniversalAudioController> logger, - MediaInfoHelper mediaInfoHelper, - AudioHelper audioHelper, - DynamicHlsHelper dynamicHlsHelper) - { - _libraryManager = libraryManager; - _logger = logger; - _mediaInfoHelper = mediaInfoHelper; - _audioHelper = audioHelper; - _dynamicHlsHelper = dynamicHlsHelper; - } - - /// <summary> - /// Gets an audio stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">Optional. The audio container.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="userId">Optional. The user id.</param> - /// <param name="audioCodec">Optional. The audio codec to transcode to.</param> - /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param> - /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="transcodingContainer">Optional. The container to transcode to.</param> - /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param> - /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> - /// <response code="200">Audio stream returned.</response> - /// <response code="302">Redirected to remote audio stream.</response> - /// <returns>A <see cref="Task"/> containing the audio file.</returns> - [HttpGet("Audio/{itemId}/universal")] - [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status302Found)] - [ProducesAudioFile] - public async Task<ActionResult> GetUniversalAudioStream( - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] Guid? userId, - [FromQuery] string? audioCodec, - [FromQuery] int? maxAudioChannels, - [FromQuery] int? transcodingAudioChannels, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] long? startTimeTicks, - [FromQuery] string? transcodingContainer, - [FromQuery] string? transcodingProtocol, - [FromQuery] int? maxAudioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] bool? enableRemoteMedia, - [FromQuery] bool breakOnNonKeyFrames = false, - [FromQuery] bool enableRedirection = true) - { - var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); + _libraryManager = libraryManager; + _logger = logger; + _mediaInfoHelper = mediaInfoHelper; + _audioHelper = audioHelper; + _dynamicHlsHelper = dynamicHlsHelper; + } - if (!userId.HasValue || userId.Value.Equals(default)) - { - userId = User.GetUserId(); - } + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">Optional. The audio container.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="userId">Optional. The user id.</param> + /// <param name="audioCodec">Optional. The audio codec to transcode to.</param> + /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param> + /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="transcodingContainer">Optional. The container to transcode to.</param> + /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param> + /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> + /// <response code="200">Audio stream returned.</response> + /// <response code="302">Redirected to remote audio stream.</response> + /// <returns>A <see cref="Task"/> containing the audio file.</returns> + [HttpGet("Audio/{itemId}/universal")] + [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesAudioFile] + public async Task<ActionResult> GetUniversalAudioStream( + [FromRoute, Required] Guid itemId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] Guid? userId, + [FromQuery] string? audioCodec, + [FromQuery] int? maxAudioChannels, + [FromQuery] int? transcodingAudioChannels, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] long? startTimeTicks, + [FromQuery] string? transcodingContainer, + [FromQuery] string? transcodingProtocol, + [FromQuery] int? maxAudioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] bool? enableRemoteMedia, + [FromQuery] bool breakOnNonKeyFrames = false, + [FromQuery] bool enableRedirection = true) + { + var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); + userId = RequestHelpers.GetUserId(User, userId); - _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); + _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); - var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, - mediaSourceId) - .ConfigureAwait(false); + var info = await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId, + mediaSourceId) + .ConfigureAwait(false); - // set device specific data - var item = _libraryManager.GetItemById(itemId); + // set device specific data + var item = _libraryManager.GetItemById(itemId); - foreach (var sourceInfo in info.MediaSources) - { - _mediaInfoHelper.SetDeviceSpecificData( - item, - sourceInfo, - deviceProfile, - User, - maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate, - startTimeTicks ?? 0, - mediaSourceId ?? string.Empty, - null, - null, - maxAudioChannels, - info.PlaySessionId!, - userId ?? Guid.Empty, - true, - true, - true, - true, - true, - Request.HttpContext.GetNormalizedRemoteIp()); - } - - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); - - foreach (var source in info.MediaSources) - { - _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video); - } + foreach (var sourceInfo in info.MediaSources) + { + _mediaInfoHelper.SetDeviceSpecificData( + item, + sourceInfo, + deviceProfile, + User, + maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate, + startTimeTicks ?? 0, + mediaSourceId ?? string.Empty, + null, + null, + maxAudioChannels, + info.PlaySessionId!, + userId ?? Guid.Empty, + true, + true, + true, + true, + true, + Request.HttpContext.GetNormalizedRemoteIp()); + } - var mediaSource = info.MediaSources[0]; - if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value) - { - return Redirect(mediaSource.Path); - } + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); - var isStatic = mediaSource.SupportsDirectStream; - if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) - { - // hls segment container can only be mpegts or fmp4 per ffmpeg documentation - // ffmpeg option -> file extension - // mpegts -> ts - // fmp4 -> mp4 - // TODO: remove this when we switch back to the segment muxer - var supportedHlsContainers = new[] { "ts", "mp4" }; + foreach (var source in info.MediaSources) + { + _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video); + } - var dynamicHlsRequestDto = new HlsAudioRequestDto - { - Id = itemId, - Container = ".m3u8", - Static = isStatic, - PlaySessionId = info.PlaySessionId, - // fallback to mpegts if device reports some weird value unsupported by hls - SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = true, - AllowAudioStreamCopy = true, - AllowVideoStreamCopy = true, - BreakOnNonKeyFrames = breakOnNonKeyFrames, - AudioSampleRate = maxAudioSampleRate, - MaxAudioChannels = maxAudioChannels, - MaxAudioBitDepth = maxAudioBitDepth, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - StartTimeTicks = startTimeTicks, - SubtitleMethod = SubtitleDeliveryMethod.Hls, - RequireAvc = false, - DeInterlace = false, - RequireNonAnamorphic = false, - EnableMpegtsM2TsMode = false, - TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), - Context = EncodingContext.Static, - StreamOptions = new Dictionary<string, string>(), - EnableAdaptiveBitrateStreaming = true - }; + var mediaSource = info.MediaSources[0]; + if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value) + { + return Redirect(mediaSource.Path); + } - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true) - .ConfigureAwait(false); - } + var isStatic = mediaSource.SupportsDirectStream; + if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) + { + // hls segment container can only be mpegts or fmp4 per ffmpeg documentation + // ffmpeg option -> file extension + // mpegts -> ts + // fmp4 -> mp4 + // TODO: remove this when we switch back to the segment muxer + var supportedHlsContainers = new[] { "ts", "mp4" }; - var audioStreamingDto = new StreamingRequestDto + var dynamicHlsRequestDto = new HlsAudioRequestDto { Id = itemId, - Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), + Container = ".m3u8", Static = isStatic, PlaySessionId = info.PlaySessionId, + // fallback to mpegts if device reports some weird value unsupported by hls + SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", MediaSourceId = mediaSourceId, DeviceId = deviceId, AudioCodec = audioCodec, @@ -220,121 +181,153 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate), MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = maxAudioChannels, - CopyTimestamps = true, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, StartTimeTicks = startTimeTicks, - SubtitleMethod = SubtitleDeliveryMethod.Embed, + SubtitleMethod = SubtitleDeliveryMethod.Hls, + RequireAvc = false, + DeInterlace = false, + RequireNonAnamorphic = false, + EnableMpegtsM2TsMode = false, TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), - Context = EncodingContext.Static + Context = EncodingContext.Static, + StreamOptions = new Dictionary<string, string>(), + EnableAdaptiveBitrateStreaming = true }; - return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true) + .ConfigureAwait(false); } - private DeviceProfile GetDeviceProfile( - string[] containers, - string? transcodingContainer, - string? audioCodec, - string? transcodingProtocol, - bool? breakOnNonKeyFrames, - int? transcodingAudioChannels, - int? maxAudioSampleRate, - int? maxAudioBitDepth, - int? maxAudioChannels) + var audioStreamingDto = new StreamingRequestDto { - var deviceProfile = new DeviceProfile(); + Id = itemId, + Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), + Static = isStatic, + PlaySessionId = info.PlaySessionId, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = true, + AllowAudioStreamCopy = true, + AllowVideoStreamCopy = true, + BreakOnNonKeyFrames = breakOnNonKeyFrames, + AudioSampleRate = maxAudioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate), + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = maxAudioChannels, + CopyTimestamps = true, + StartTimeTicks = startTimeTicks, + SubtitleMethod = SubtitleDeliveryMethod.Embed, + TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), + Context = EncodingContext.Static + }; - int len = containers.Length; - var directPlayProfiles = new DirectPlayProfile[len]; - for (int i = 0; i < len; i++) - { - var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries); + return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false); + } - var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); + private DeviceProfile GetDeviceProfile( + string[] containers, + string? transcodingContainer, + string? audioCodec, + string? transcodingProtocol, + bool? breakOnNonKeyFrames, + int? transcodingAudioChannels, + int? maxAudioSampleRate, + int? maxAudioBitDepth, + int? maxAudioChannels) + { + var deviceProfile = new DeviceProfile(); - directPlayProfiles[i] = new DirectPlayProfile - { - Type = DlnaProfileType.Audio, - Container = parts[0], - AudioCodec = audioCodecs - }; - } + int len = containers.Length; + var directPlayProfiles = new DirectPlayProfile[len]; + for (int i = 0; i < len; i++) + { + var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries); - deviceProfile.DirectPlayProfiles = directPlayProfiles; + var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); - deviceProfile.TranscodingProfiles = new[] + directPlayProfiles[i] = new DirectPlayProfile { - new TranscodingProfile - { - Type = DlnaProfileType.Audio, - Context = EncodingContext.Streaming, - Container = transcodingContainer ?? "mp3", - AudioCodec = audioCodec ?? "mp3", - Protocol = transcodingProtocol ?? "http", - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) - } + Type = DlnaProfileType.Audio, + Container = parts[0], + AudioCodec = audioCodecs }; + } - var codecProfiles = new List<CodecProfile>(); - var conditions = new List<ProfileCondition>(); + deviceProfile.DirectPlayProfiles = directPlayProfiles; - if (maxAudioSampleRate.HasValue) + deviceProfile.TranscodingProfiles = new[] + { + new TranscodingProfile { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioSampleRate, - Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) - }); + Type = DlnaProfileType.Audio, + Context = EncodingContext.Streaming, + Container = transcodingContainer ?? "mp3", + AudioCodec = audioCodec ?? "mp3", + Protocol = transcodingProtocol ?? "http", + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) } + }; - if (maxAudioBitDepth.HasValue) - { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioBitDepth, - Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture) - }); - } + var codecProfiles = new List<CodecProfile>(); + var conditions = new List<ProfileCondition>(); - if (maxAudioChannels.HasValue) - { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioChannels, - Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) - }); - } + if (maxAudioSampleRate.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioSampleRate, + Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) + }); + } - if (conditions.Count > 0) - { - // codec profile - codecProfiles.Add( - new CodecProfile - { - Type = CodecType.Audio, - Container = string.Join(',', containers), - Conditions = conditions.ToArray() - }); - } + if (maxAudioBitDepth.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioBitDepth, + Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture) + }); + } - deviceProfile.CodecProfiles = codecProfiles.ToArray(); + if (maxAudioChannels.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioChannels, + Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) + }); + } - return deviceProfile; + if (conditions.Count > 0) + { + // codec profile + codecProfiles.Add( + new CodecProfile + { + Type = CodecType.Audio, + Container = string.Join(',', containers), + Conditions = conditions.ToArray() + }); } + + deviceProfile.CodecProfiles = codecProfiles.ToArray(); + + return deviceProfile; } } diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 568224a424..530bd96031 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -15,6 +15,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Configuration; @@ -25,564 +26,561 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// User controller. +/// </summary> +[Route("Users")] +public class UserController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + private readonly IAuthorizationContext _authContext; + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IQuickConnect _quickConnectManager; + private readonly IPlaylistManager _playlistManager; + + /// <summary> + /// Initializes a new instance of the <see cref="UserController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param> + /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> + public UserController( + IUserManager userManager, + ISessionManager sessionManager, + INetworkManager networkManager, + IDeviceManager deviceManager, + IAuthorizationContext authContext, + IServerConfigurationManager config, + ILogger<UserController> logger, + IQuickConnect quickConnectManager, + IPlaylistManager playlistManager) + { + _userManager = userManager; + _sessionManager = sessionManager; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + _config = config; + _logger = logger; + _quickConnectManager = quickConnectManager; + _playlistManager = playlistManager; + } + + /// <summary> + /// Gets a list of users. + /// </summary> + /// <param name="isHidden">Optional filter by IsHidden=true or false.</param> + /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param> + /// <response code="200">Users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns> + [HttpGet] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetUsers( + [FromQuery] bool? isHidden, + [FromQuery] bool? isDisabled) + { + var users = Get(isHidden, isDisabled, false, false); + return Ok(users); + } + /// <summary> - /// User controller. + /// Gets a list of publicly visible users for display on a login screen. /// </summary> - [Route("Users")] - public class UserController : BaseJellyfinApiController + /// <response code="200">Public users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> + [HttpGet("Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetPublicUsers() { - private readonly IUserManager _userManager; - private readonly ISessionManager _sessionManager; - private readonly INetworkManager _networkManager; - private readonly IDeviceManager _deviceManager; - private readonly IAuthorizationContext _authContext; - private readonly IServerConfigurationManager _config; - private readonly ILogger _logger; - private readonly IQuickConnect _quickConnectManager; - - /// <summary> - /// Initializes a new instance of the <see cref="UserController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> - /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - /// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param> - public UserController( - IUserManager userManager, - ISessionManager sessionManager, - INetworkManager networkManager, - IDeviceManager deviceManager, - IAuthorizationContext authContext, - IServerConfigurationManager config, - ILogger<UserController> logger, - IQuickConnect quickConnectManager) + // If the startup wizard hasn't been completed then just return all users + if (!_config.Configuration.IsStartupWizardCompleted) { - _userManager = userManager; - _sessionManager = sessionManager; - _networkManager = networkManager; - _deviceManager = deviceManager; - _authContext = authContext; - _config = config; - _logger = logger; - _quickConnectManager = quickConnectManager; + return Ok(Get(false, false, false, false)); } - /// <summary> - /// Gets a list of users. - /// </summary> - /// <param name="isHidden">Optional filter by IsHidden=true or false.</param> - /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param> - /// <response code="200">Users returned.</response> - /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns> - [HttpGet] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<UserDto>> GetUsers( - [FromQuery] bool? isHidden, - [FromQuery] bool? isDisabled) + return Ok(Get(false, false, true, true)); + } + + /// <summary> + /// Gets a user by Id. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="200">User returned.</response> + /// <response code="404">User not found.</response> + /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpGet("{userId}")] + [Authorize(Policy = Policies.IgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + + if (user is null) { - var users = Get(isHidden, isDisabled, false, false); - return Ok(users); + return NotFound("User not found"); } - /// <summary> - /// Gets a list of publicly visible users for display on a login screen. - /// </summary> - /// <response code="200">Public users returned.</response> - /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> - [HttpGet("Public")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<UserDto>> GetPublicUsers() + var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); + return result; + } + + /// <summary> + /// Deletes a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="204">User deleted.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpDelete("{userId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + if (user is null) { - // If the startup wizard hasn't been completed then just return all users - if (!_config.Configuration.IsStartupWizardCompleted) - { - return Ok(Get(false, false, false, false)); - } + return NotFound(); + } + + await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); + await _playlistManager.RemovePlaylistsAsync(userId).ConfigureAwait(false); + await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Authenticates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="pw">The password as plain text.</param> + /// <response code="200">User authenticated.</response> + /// <response code="403">Sha1-hashed password only is not allowed.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns> + [HttpPost("{userId}/Authenticate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Authenticate with username instead")] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( + [FromRoute, Required] Guid userId, + [FromQuery, Required] string pw) + { + var user = _userManager.GetUserById(userId); - return Ok(Get(false, false, true, true)); + if (user is null) + { + return NotFound("User not found"); } - /// <summary> - /// Gets a user by Id. - /// </summary> - /// <param name="userId">The user id.</param> - /// <response code="200">User returned.</response> - /// <response code="404">User not found.</response> - /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns> - [HttpGet("{userId}")] - [Authorize(Policy = Policies.IgnoreParentalControl)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId) + AuthenticateUserByName request = new AuthenticateUserByName { - var user = _userManager.GetUserById(userId); + Username = user.Username, + Pw = pw + }; + return await AuthenticateUserByName(request).ConfigureAwait(false); + } - if (user is null) + /// <summary> + /// Authenticates a user by name. + /// </summary> + /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param> + /// <response code="200">User authenticated.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> + [HttpPost("AuthenticateByName")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) + { + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + + try + { + var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest { - return NotFound("User not found"); - } + App = auth.Client, + AppVersion = auth.Version, + DeviceId = auth.DeviceId, + DeviceName = auth.Device, + Password = request.Pw, + RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(), + Username = request.Username + }).ConfigureAwait(false); - var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); return result; } - - /// <summary> - /// Deletes a user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <response code="204">User deleted.</response> - /// <response code="404">User not found.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns> - [HttpDelete("{userId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId) + catch (SecurityException e) { - var user = _userManager.GetUserById(userId); - await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); - await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); - return NoContent(); + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); } + } - /// <summary> - /// Authenticates a user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="pw">The password as plain text.</param> - /// <response code="200">User authenticated.</response> - /// <response code="403">Sha1-hashed password only is not allowed.</response> - /// <response code="404">User not found.</response> - /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns> - [HttpPost("{userId}/Authenticate")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("Authenticate with username instead")] - public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( - [FromRoute, Required] Guid userId, - [FromQuery, Required] string pw) + /// <summary> + /// Authenticates a user with quick connect. + /// </summary> + /// <param name="request">The <see cref="QuickConnectDto"/> request.</param> + /// <response code="200">User authenticated.</response> + /// <response code="400">Missing token.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> + [HttpPost("AuthenticateWithQuickConnect")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) + { + try { - var user = _userManager.GetUserById(userId); - - if (user is null) - { - return NotFound("User not found"); - } - - AuthenticateUserByName request = new AuthenticateUserByName - { - Username = user.Username, - Pw = pw - }; - return await AuthenticateUserByName(request).ConfigureAwait(false); + return _quickConnectManager.GetAuthorizedRequest(request.Secret); } - - /// <summary> - /// Authenticates a user by name. - /// </summary> - /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param> - /// <response code="200">User authenticated.</response> - /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> - [HttpPost("AuthenticateByName")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) + catch (SecurityException e) { - var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); + } + } - try - { - var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest - { - App = auth.Client, - AppVersion = auth.Version, - DeviceId = auth.DeviceId, - DeviceName = auth.Device, - Password = request.Pw, - RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(), - Username = request.Username - }).ConfigureAwait(false); - - return result; - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } + /// <summary> + /// Updates a user's password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> + /// <response code="204">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{userId}/Password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateUserPassword( + [FromRoute, Required] Guid userId, + [FromBody, Required] UpdateUserPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); } - /// <summary> - /// Authenticates a user with quick connect. - /// </summary> - /// <param name="request">The <see cref="QuickConnectDto"/> request.</param> - /// <response code="200">User authenticated.</response> - /// <response code="400">Missing token.</response> - /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> - [HttpPost("AuthenticateWithQuickConnect")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) + var user = _userManager.GetUserById(userId); + + if (user is null) { - try - { - return _quickConnectManager.GetAuthorizedRequest(request.Secret); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } + return NotFound("User not found"); } - /// <summary> - /// Updates a user's password. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> - /// <response code="204">Password successfully reset.</response> - /// <response code="403">User is not allowed to update the password.</response> - /// <response code="404">User not found.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> - [HttpPost("{userId}/Password")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateUserPassword( - [FromRoute, Required] Guid userId, - [FromBody, Required] UpdateUserPassword request) + if (request.ResetPassword) + { + await _userManager.ResetPassword(user).ConfigureAwait(false); + } + else { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId)) { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); + var success = await _userManager.AuthenticateUser( + user.Username, + request.CurrentPw ?? string.Empty, + request.CurrentPw ?? string.Empty, + HttpContext.GetNormalizedRemoteIp().ToString(), + false).ConfigureAwait(false); + + if (success is null) + { + return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); + } } - var user = _userManager.GetUserById(userId); - - if (user is null) - { - return NotFound("User not found"); - } + await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false); - if (request.ResetPassword) - { - await _userManager.ResetPassword(user).ConfigureAwait(false); - } - else - { - if (!User.IsInRole(UserRoles.Administrator)) - { - var success = await _userManager.AuthenticateUser( - user.Username, - request.CurrentPw, - request.CurrentPw, - HttpContext.GetNormalizedRemoteIp().ToString(), - false).ConfigureAwait(false); - - if (success is null) - { - return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); - } - } + var currentToken = User.GetToken(); - await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); + } - var currentToken = User.GetToken(); + return NoContent(); + } - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } + /// <summary> + /// Updates a user's easy password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> + /// <response code="204">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{userId}/EasyPassword")] + [Obsolete("Use Quick Connect instead")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateUserEasyPassword( + [FromRoute, Required] Guid userId, + [FromBody, Required] UpdateUserEasyPassword request) + { + return Forbid(); + } - return NoContent(); + /// <summary> + /// Updates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="updateUser">The updated user model.</param> + /// <response code="204">User updated.</response> + /// <response code="400">User information was not supplied.</response> + /// <response code="403">User update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns> + [HttpPost("{userId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUser( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserDto updateUser) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); } - /// <summary> - /// Updates a user's easy password. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> - /// <response code="204">Password successfully reset.</response> - /// <response code="403">User is not allowed to update the password.</response> - /// <response code="404">User not found.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> - [HttpPost("{userId}/EasyPassword")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateUserEasyPassword( - [FromRoute, Required] Guid userId, - [FromBody, Required] UpdateUserEasyPassword request) + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); - } + return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); + } - var user = _userManager.GetUserById(userId); + if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + { + await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + } - if (user is null) - { - return NotFound("User not found"); - } + await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); - if (request.ResetPassword) - { - await _userManager.ResetEasyPassword(user).ConfigureAwait(false); - } - else - { - await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false); - } + return NoContent(); + } - return NoContent(); + /// <summary> + /// Updates a user policy. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="newPolicy">The new user policy.</param> + /// <response code="204">User policy updated.</response> + /// <response code="400">User policy was not supplied.</response> + /// <response code="403">User policy update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns> + [HttpPost("{userId}/Policy")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUserPolicy( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserPolicy newPolicy) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); } - /// <summary> - /// Updates a user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="updateUser">The updated user model.</param> - /// <response code="204">User updated.</response> - /// <response code="400">User information was not supplied.</response> - /// <response code="403">User update forbidden.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns> - [HttpPost("{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> UpdateUser( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserDto updateUser) + // If removing admin access + if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) { - return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); } + } - var user = _userManager.GetUserById(userId); + // If disabling + if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); + } - if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + // If disabling + if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) + { + if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { - await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } - await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); - - return NoContent(); + var currentToken = User.GetToken(); + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); } - /// <summary> - /// Updates a user policy. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="newPolicy">The new user policy.</param> - /// <response code="204">User policy updated.</response> - /// <response code="400">User policy was not supplied.</response> - /// <response code="403">User policy update forbidden.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns> - [HttpPost("{userId}/Policy")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> UpdateUserPolicy( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserPolicy newPolicy) - { - var user = _userManager.GetUserById(userId); + await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); - // If removing admin access - if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) - { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); - } - } + return NoContent(); + } - // If disabling - if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) - { - return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); - } + /// <summary> + /// Updates a user configuration. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="userConfig">The new user configuration.</param> + /// <response code="204">User configuration updated.</response> + /// <response code="403">User configuration update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{userId}/Configuration")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUserConfiguration( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserConfiguration userConfig) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); + } - // If disabling - if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) - { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) - { - return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); - } + await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); - var currentToken = User.GetToken(); - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } + return NoContent(); + } - await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); + /// <summary> + /// Creates a user. + /// </summary> + /// <param name="request">The create user by name request body.</param> + /// <response code="200">User created.</response> + /// <returns>An <see cref="UserDto"/> of the new user.</returns> + [HttpPost("New")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request) + { + var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); - return NoContent(); + // no need to authenticate password for new user + if (request.Password is not null) + { + await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); } - /// <summary> - /// Updates a user configuration. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="userConfig">The new user configuration.</param> - /// <response code="204">User configuration updated.</response> - /// <response code="403">User configuration update forbidden.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("{userId}/Configuration")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> UpdateUserConfiguration( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserConfiguration userConfig) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); - } + var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); - await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); + return result; + } - return NoContent(); - } + /// <summary> + /// Initiates the forgot password process for a local user. + /// </summary> + /// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param> + /// <response code="200">Password reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> + [HttpPost("ForgotPassword")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) + { + var ip = HttpContext.GetNormalizedRemoteIp(); + var isLocal = HttpContext.IsLocal() + || _networkManager.IsInLocalNetwork(ip); - /// <summary> - /// Creates a user. - /// </summary> - /// <param name="request">The create user by name request body.</param> - /// <response code="200">User created.</response> - /// <returns>An <see cref="UserDto"/> of the new user.</returns> - [HttpPost("New")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request) + if (isLocal) { - var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); + _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); + } - // no need to authenticate password for new user - if (request.Password is not null) - { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); - } + var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); - var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); + return result; + } - return result; + /// <summary> + /// Redeems a forgot password pin. + /// </summary> + /// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param> + /// <response code="200">Pin reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> + [HttpPost("ForgotPassword/Pin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) + { + var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); + return result; + } + + /// <summary> + /// Gets the user based on auth token. + /// </summary> + /// <response code="200">User returned.</response> + /// <response code="400">Token is not owned by a user.</response> + /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns> + [HttpGet("Me")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult<UserDto> GetCurrentUser() + { + var userId = User.GetUserId(); + if (userId.Equals(default)) + { + return BadRequest(); } - /// <summary> - /// Initiates the forgot password process for a local user. - /// </summary> - /// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param> - /// <response code="200">Password reset process started.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> - [HttpPost("ForgotPassword")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) + var user = _userManager.GetUserById(userId); + if (user is null) { - var ip = HttpContext.GetNormalizedRemoteIp(); - var isLocal = HttpContext.IsLocal() - || _networkManager.IsInLocalNetwork(ip); + return BadRequest(); + } - if (isLocal) - { - _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); - } + return _userManager.GetUserDto(user); + } - var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); + private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + { + var users = _userManager.Users; - return result; + if (isDisabled.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); } - /// <summary> - /// Redeems a forgot password pin. - /// </summary> - /// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param> - /// <response code="200">Pin reset process started.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> - [HttpPost("ForgotPassword/Pin")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) + if (isHidden.HasValue) { - var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); - return result; + users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); } - /// <summary> - /// Gets the user based on auth token. - /// </summary> - /// <response code="200">User returned.</response> - /// <response code="400">Token is not owned by a user.</response> - /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns> - [HttpGet("Me")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult<UserDto> GetCurrentUser() + if (filterByDevice) { - var userId = User.GetUserId(); - if (userId.Equals(default)) - { - return BadRequest(); - } + var deviceId = User.GetDeviceId(); - var user = _userManager.GetUserById(userId); - if (user is null) + if (!string.IsNullOrWhiteSpace(deviceId)) { - return BadRequest(); + users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); } - - return _userManager.GetUserDto(user); } - private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + if (filterByNetwork) { - var users = _userManager.Users; - - if (isDisabled.HasValue) + if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) { - users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); - } - - if (isHidden.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); - } - - if (filterByDevice) - { - var deviceId = User.GetDeviceId(); - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); - } - } - - if (filterByNetwork) - { - if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) - { - users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); - } + users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); } + } - var result = users - .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString())); + var result = users + .OrderBy(u => u.Username) + .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString())); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index cd21c5f6ff..2c4fe91862 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -4,10 +4,9 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; -using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -23,406 +22,564 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// User library controller. +/// </summary> +[Route("")] +[Authorize] +public class UserLibraryController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataRepository; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserViewManager _userViewManager; + private readonly IFileSystem _fileSystem; + private readonly ILyricManager _lyricManager; + /// <summary> - /// User library controller. + /// Initializes a new instance of the <see cref="UserLibraryController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class UserLibraryController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> 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="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> + public UserLibraryController( + IUserManager userManager, + IUserDataManager userDataRepository, + ILibraryManager libraryManager, + IDtoService dtoService, + IUserViewManager userViewManager, + IFileSystem fileSystem, + ILyricManager lyricManager) { - private readonly IUserManager _userManager; - private readonly IUserDataManager _userDataRepository; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserViewManager _userViewManager; - private readonly IFileSystem _fileSystem; - private readonly ILyricManager _lyricManager; - - /// <summary> - /// Initializes a new instance of the <see cref="UserLibraryController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> 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="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> - public UserLibraryController( - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - IDtoService dtoService, - IUserViewManager userViewManager, - IFileSystem fileSystem, - ILyricManager lyricManager) - { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _dtoService = dtoService; - _userViewManager = userViewManager; - _fileSystem = fileSystem; - _lyricManager = lyricManager; - } - - /// <summary> - /// Gets an item from a user's library. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item returned.</response> - /// <returns>An <see cref="OkResult"/> containing the d item.</returns> - [HttpGet("Users/{userId}/Items/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); - - var dtoOptions = new DtoOptions().AddClientFields(User); - - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } - - /// <summary> - /// Gets the root folder from a user's library. - /// </summary> - /// <param name="userId">User id.</param> - /// <response code="200">Root folder returned.</response> - /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns> - [HttpGet("Users/{userId}/Items/Root")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId) - { - var user = _userManager.GetUserById(userId); - var item = _libraryManager.GetUserRootFolder(); - var dtoOptions = new DtoOptions().AddClientFields(User); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } - - /// <summary> - /// Gets intros to play before the main media item plays. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Intros returned.</response> - /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/Intros")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - - var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); - var dtoOptions = new DtoOptions().AddClientFields(User); - var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); - - return new QueryResult<BaseItemDto>(dtos); - } - - /// <summary> - /// Marks an item as a favorite. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item marked as favorite.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return MarkFavorite(userId, itemId, true); - } - - /// <summary> - /// Unmarks item as a favorite. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item unmarked as favorite.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return MarkFavorite(userId, itemId, false); - } - - /// <summary> - /// Deletes a user's saved personal rating for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Personal rating removed.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return UpdateUserItemRatingInternal(userId, itemId, null); - } - - /// <summary> - /// Updates a user's rating for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param> - /// <response code="200">Item rating updated.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpPost("Users/{userId}/Items/{itemId}/Rating")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) - { - return UpdateUserItemRatingInternal(userId, itemId, likes); - } - - /// <summary> - /// Gets local trailers for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response> - /// <returns>The items local trailers.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - - var dtoOptions = new DtoOptions().AddClientFields(User); - - if (item is IHasTrailers hasTrailers) - { - var trailers = hasTrailers.LocalTrailers; - return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); - } + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _dtoService = dtoService; + _userViewManager = userViewManager; + _fileSystem = fileSystem; + _lyricManager = lyricManager; + } - return Ok(item.GetExtras() - .Where(e => e.ExtraType == ExtraType.Trailer) - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); - } - - /// <summary> - /// Gets special features for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Special features returned.</response> - /// <returns>An <see cref="OkResult"/> containing the special features.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - - var dtoOptions = new DtoOptions().AddClientFields(User); - - return Ok(item - .GetExtras() - .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); - } - - /// <summary> - /// Gets latest media. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="isPlayed">Filter by items that are played, or not.</param> - /// <param name="enableImages">Optional. include image information in output.</param> - /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. include user data.</param> - /// <param name="limit">Return item limit.</param> - /// <param name="groupItems">Whether or not to group items into a parent container.</param> - /// <response code="200">Latest media returned.</response> - /// <returns>An <see cref="OkResult"/> containing the latest media.</returns> - [HttpGet("Users/{userId}/Items/Latest")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia( - [FromRoute, Required] Guid userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isPlayed, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] int limit = 20, - [FromQuery] bool groupItems = true) - { - var user = _userManager.GetUserById(userId); - - if (!isPlayed.HasValue) - { - if (user.HidePlayedInLatest) - { - isPlayed = false; - } - } + /// <summary> + /// Gets an item from a user's library. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item returned.</response> + /// <returns>An <see cref="OkResult"/> containing the item.</returns> + [HttpGet("Users/{userId}/Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - var list = _userViewManager.GetLatestItems( - new LatestItemsQuery - { - GroupItems = groupItems, - IncludeItemTypes = includeItemTypes, - IsPlayed = isPlayed, - Limit = limit, - ParentId = parentId ?? Guid.Empty, - UserId = userId, - }, - dtoOptions); - - var dtos = list.Select(i => - { - var item = i.Item2[0]; - var childCount = 0; + if (item is null) + { + return NotFound(); + } - if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) - { - item = i.Item1; - childCount = i.Item2.Count; - } + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } + + await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + + var dtoOptions = new DtoOptions().AddClientFields(User); - var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + /// <summary> + /// Gets the root folder from a user's library. + /// </summary> + /// <param name="userId">User id.</param> + /// <response code="200">Root folder returned.</response> + /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns> + [HttpGet("Users/{userId}/Items/Root")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + var item = _libraryManager.GetUserRootFolder(); + var dtoOptions = new DtoOptions().AddClientFields(User); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - dto.ChildCount = childCount; + /// <summary> + /// Gets intros to play before the main media item plays. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Intros returned.</response> + /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/Intros")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } - return dto; - }); + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - return Ok(dtos); + if (item is null) + { + return NotFound(); } - private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) { - if (item is Person) - { - var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } - if (!hasMetdata) - { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = performFullRefresh - }; - - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } - } + var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); + var dtoOptions = new DtoOptions().AddClientFields(User); + var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); + + return new QueryResult<BaseItemDto>(dtos); + } + + /// <summary> + /// Marks an item as a favorite. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item marked as favorite.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound(); + } + + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } + + return MarkFavorite(user, item, true); + } + + /// <summary> + /// Unmarks item as a favorite. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item unmarked as favorite.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound(); + } + + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } + + return MarkFavorite(user, item, false); + } + + /// <summary> + /// Deletes a user's saved personal rating for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Personal rating removed.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound(); + } + + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); } - /// <summary> - /// Marks the favorite. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param> - private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite) + return UpdateUserItemRatingInternal(user, item, null); + } + + /// <summary> + /// Updates a user's rating for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param> + /// <response code="200">Item rating updated.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpPost("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound(); + } + + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) { - var user = _userManager.GetUserById(userId); + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } - var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); + return UpdateUserItemRatingInternal(user, item, likes); + } - // Get the user data for this item - var data = _userDataRepository.GetUserData(user, item); + /// <summary> + /// Gets local trailers for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response> + /// <returns>The items local trailers.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } - // Set favorite status - data.IsFavorite = isFavorite; + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + if (item is null) + { + return NotFound(); + } - return _userDataRepository.GetUserDataDto(item, user); + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); } - /// <summary> - /// Updates the user item rating. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="likes">if set to <c>true</c> [likes].</param> - private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes) + var dtoOptions = new DtoOptions().AddClientFields(User); + if (item is IHasTrailers hasTrailers) { - var user = _userManager.GetUserById(userId); + var trailers = hasTrailers.LocalTrailers; + return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); + } - var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); + return Ok(item.GetExtras() + .Where(e => e.ExtraType == ExtraType.Trailer) + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); + } - // Get the user data for this item - var data = _userDataRepository.GetUserData(user, item); + /// <summary> + /// Gets special features for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Special features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the special features.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } - data.Likes = likes; + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + if (item is null) + { + return NotFound(); + } - return _userDataRepository.GetUserDataDto(item, user); + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); } - /// <summary> - /// Gets an item's lyrics. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Lyrics returned.</response> - /// <response code="404">Something went wrong. No Lyrics will be returned.</response> - /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + var dtoOptions = new DtoOptions().AddClientFields(User); + + return Ok(item + .GetExtras() + .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); + } + + /// <summary> + /// Gets latest media. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="isPlayed">Filter by items that are played, or not.</param> + /// <param name="enableImages">Optional. include image information in output.</param> + /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. include user data.</param> + /// <param name="limit">Return item limit.</param> + /// <param name="groupItems">Whether or not to group items into a parent container.</param> + /// <response code="200">Latest media returned.</response> + /// <returns>An <see cref="OkResult"/> containing the latest media.</returns> + [HttpGet("Users/{userId}/Items/Latest")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia( + [FromRoute, Required] Guid userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isPlayed, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] int limit = 20, + [FromQuery] bool groupItems = true) + { + var user = _userManager.GetUserById(userId); + if (user is null) { - var user = _userManager.GetUserById(userId); + return NotFound(); + } - if (user is null) + if (!isPlayed.HasValue) + { + if (user.HidePlayedInLatest) { - return NotFound(); + isPlayed = false; } + } - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - if (item is null) + var list = _userViewManager.GetLatestItems( + new LatestItemsQuery { - return NotFound(); + GroupItems = groupItems, + IncludeItemTypes = includeItemTypes, + IsPlayed = isPlayed, + Limit = limit, + ParentId = parentId ?? Guid.Empty, + UserId = userId, + }, + dtoOptions); + + var dtos = list.Select(i => + { + var item = i.Item2[0]; + var childCount = 0; + + if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) + { + item = i.Item1; + childCount = i.Item2.Count; } - var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); - if (result is not null) + var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user); + + dto.ChildCount = childCount; + + return dto; + }); + + return Ok(dtos); + } + + private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + { + if (item is Person) + { + var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + + if (!hasMetdata) { - return Ok(result); + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = performFullRefresh + }; + + await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); } + } + } + + /// <summary> + /// Marks the favorite. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param> + private UserItemDataDto MarkFavorite(User user, BaseItem item, bool isFavorite) + { + // Get the user data for this item + var data = _userDataRepository.GetUserData(user, item); + + // Set favorite status + data.IsFavorite = isFavorite; + + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + + return _userDataRepository.GetUserDataDto(item, user); + } + + /// <summary> + /// Updates the user item rating. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="likes">if set to <c>true</c> [likes].</param> + private UserItemDataDto UpdateUserItemRatingInternal(User user, BaseItem item, bool? likes) + { + // Get the user data for this item + var data = _userDataRepository.GetUserData(user, item); + + data.Likes = likes; + + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + + return _userDataRepository.GetUserDataDto(item, user); + } + + /// <summary> + /// Gets an item's lyrics. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Lyrics returned.</response> + /// <response code="404">Something went wrong. No Lyrics will be returned.</response> + /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + + if (user is null) + { + return NotFound(); + } + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + if (item is null) + { return NotFound(); } + + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } + + var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); + if (result is not null) + { + return Ok(result); + } + + return NotFound(); } } diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 3aeb444dfa..838b432340 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.UserViewDtos; @@ -17,122 +16,121 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// User views controller. +/// </summary> +[Route("")] +[Authorize] +public class UserViewsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IUserViewManager _userViewManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + /// <summary> - /// User views controller. + /// Initializes a new instance of the <see cref="UserViewsController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class UserViewsController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public UserViewsController( + IUserManager userManager, + IUserViewManager userViewManager, + IDtoService dtoService, + ILibraryManager libraryManager) { - private readonly IUserManager _userManager; - private readonly IUserViewManager _userViewManager; - private readonly IDtoService _dtoService; - private readonly ILibraryManager _libraryManager; + _userManager = userManager; + _userViewManager = userViewManager; + _dtoService = dtoService; + _libraryManager = libraryManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="UserViewsController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public UserViewsController( - IUserManager userManager, - IUserViewManager userViewManager, - IDtoService dtoService, - ILibraryManager libraryManager) + /// <summary> + /// Get user views. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param> + /// <param name="presetViews">Preset views.</param> + /// <param name="includeHidden">Whether or not to include hidden content.</param> + /// <response code="200">User views returned.</response> + /// <returns>An <see cref="OkResult"/> containing the user views.</returns> + [HttpGet("Users/{userId}/Views")] + [ProducesResponseType(StatusCodes.Status200OK)] + public QueryResult<BaseItemDto> GetUserViews( + [FromRoute, Required] Guid userId, + [FromQuery] bool? includeExternalContent, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, + [FromQuery] bool includeHidden = false) + { + var query = new UserViewQuery { - _userManager = userManager; - _userViewManager = userViewManager; - _dtoService = dtoService; - _libraryManager = libraryManager; - } + UserId = userId, + IncludeHidden = includeHidden + }; - /// <summary> - /// Get user views. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param> - /// <param name="presetViews">Preset views.</param> - /// <param name="includeHidden">Whether or not to include hidden content.</param> - /// <response code="200">User views returned.</response> - /// <returns>An <see cref="OkResult"/> containing the user views.</returns> - [HttpGet("Users/{userId}/Views")] - [ProducesResponseType(StatusCodes.Status200OK)] - public QueryResult<BaseItemDto> GetUserViews( - [FromRoute, Required] Guid userId, - [FromQuery] bool? includeExternalContent, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, - [FromQuery] bool includeHidden = false) + if (includeExternalContent.HasValue) { - var query = new UserViewQuery - { - UserId = userId, - IncludeHidden = includeHidden - }; + query.IncludeExternalContent = includeExternalContent.Value; + } - if (includeExternalContent.HasValue) - { - query.IncludeExternalContent = includeExternalContent.Value; - } + if (presetViews.Length != 0) + { + query.PresetViews = presetViews; + } - if (presetViews.Length != 0) - { - query.PresetViews = presetViews; - } + var folders = _userViewManager.GetUserViews(query); - var folders = _userViewManager.GetUserViews(query); + var dtoOptions = new DtoOptions().AddClientFields(User); + var fields = dtoOptions.Fields.ToList(); - var dtoOptions = new DtoOptions().AddClientFields(User); - var fields = dtoOptions.Fields.ToList(); + fields.Add(ItemFields.PrimaryImageAspectRatio); + fields.Add(ItemFields.DisplayPreferencesId); + fields.Remove(ItemFields.BasicSyncInfo); + dtoOptions.Fields = fields.ToArray(); - fields.Add(ItemFields.PrimaryImageAspectRatio); - fields.Add(ItemFields.DisplayPreferencesId); - fields.Remove(ItemFields.BasicSyncInfo); - dtoOptions.Fields = fields.ToArray(); + var user = _userManager.GetUserById(userId); - var user = _userManager.GetUserById(userId); + var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) + .ToArray(); - var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) - .ToArray(); + return new QueryResult<BaseItemDto>(dtos); + } - return new QueryResult<BaseItemDto>(dtos); + /// <summary> + /// Get user view grouping options. + /// </summary> + /// <param name="userId">User id.</param> + /// <response code="200">User view grouping options returned.</response> + /// <response code="404">User not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the user view grouping options + /// or a <see cref="NotFoundResult"/> if user not found. + /// </returns> + [HttpGet("Users/{userId}/GroupingOptions")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); } - /// <summary> - /// Get user view grouping options. - /// </summary> - /// <param name="userId">User id.</param> - /// <response code="200">User view grouping options returned.</response> - /// <response code="404">User not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the user view grouping options - /// or a <see cref="NotFoundResult"/> if user not found. - /// </returns> - [HttpGet("Users/{userId}/GroupingOptions")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId) - { - var user = _userManager.GetUserById(userId); - if (user is null) + return Ok(_libraryManager.GetUserRootFolder() + .GetChildren(user, true) + .OfType<Folder>() + .Where(UserView.IsEligibleForGrouping) + .Select(i => new SpecialViewOptionDto { - return NotFound(); - } - - return Ok(_libraryManager.GetUserRootFolder() - .GetChildren(user, true) - .OfType<Folder>() - .Where(UserView.IsEligibleForGrouping) - .Select(i => new SpecialViewOptionDto - { - Name = i.Name, - Id = i.Id.ToString("N", CultureInfo.InvariantCulture) - }) - .OrderBy(i => i.Name) - .AsEnumerable()); - } + Name = i.Name, + Id = i.Id.ToString("N", CultureInfo.InvariantCulture) + }) + .OrderBy(i => i.Name) + .AsEnumerable()); } } diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index bb31626142..23b9ba46f6 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -10,73 +10,72 @@ using MediaBrowser.Controller.MediaEncoding; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Attachments controller. +/// </summary> +[Route("Videos")] +public class VideoAttachmentsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IAttachmentExtractor _attachmentExtractor; + /// <summary> - /// Attachments controller. + /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class. /// </summary> - [Route("Videos")] - public class VideoAttachmentsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> + public VideoAttachmentsController( + ILibraryManager libraryManager, + IAttachmentExtractor attachmentExtractor) { - private readonly ILibraryManager _libraryManager; - private readonly IAttachmentExtractor _attachmentExtractor; - - /// <summary> - /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> - public VideoAttachmentsController( - ILibraryManager libraryManager, - IAttachmentExtractor attachmentExtractor) - { - _libraryManager = libraryManager; - _attachmentExtractor = attachmentExtractor; - } + _libraryManager = libraryManager; + _attachmentExtractor = attachmentExtractor; + } - /// <summary> - /// Get video attachment. - /// </summary> - /// <param name="videoId">Video ID.</param> - /// <param name="mediaSourceId">Media Source ID.</param> - /// <param name="index">Attachment Index.</param> - /// <response code="200">Attachment retrieved.</response> - /// <response code="404">Video or attachment not found.</response> - /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> - [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] - [ProducesFile(MediaTypeNames.Application.Octet)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> GetAttachment( - [FromRoute, Required] Guid videoId, - [FromRoute, Required] string mediaSourceId, - [FromRoute, Required] int index) + /// <summary> + /// Get video attachment. + /// </summary> + /// <param name="videoId">Video ID.</param> + /// <param name="mediaSourceId">Media Source ID.</param> + /// <param name="index">Attachment Index.</param> + /// <response code="200">Attachment retrieved.</response> + /// <response code="404">Video or attachment not found.</response> + /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> + [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] + [ProducesFile(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetAttachment( + [FromRoute, Required] Guid videoId, + [FromRoute, Required] string mediaSourceId, + [FromRoute, Required] int index) + { + try { - try + var item = _libraryManager.GetItemById(videoId); + if (item is null) { - var item = _libraryManager.GetItemById(videoId); - if (item is null) - { - return NotFound(); - } + return NotFound(); + } - var (attachment, stream) = await _attachmentExtractor.GetAttachment( - item, - mediaSourceId, - index, - CancellationToken.None) - .ConfigureAwait(false); + var (attachment, stream) = await _attachmentExtractor.GetAttachment( + item, + mediaSourceId, + index, + CancellationToken.None) + .ConfigureAwait(false); - var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) - ? MediaTypeNames.Application.Octet - : attachment.MimeType; + var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) + ? MediaTypeNames.Application.Octet + : attachment.MimeType; - return new FileStreamResult(stream, contentType); - } - catch (ResourceNotFoundException e) - { - return NotFound(e.Message); - } + return new FileStreamResult(stream, contentType); + } + catch (ResourceNotFoundException e) + { + return NotFound(e.Message); } } } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 64d8fb498b..c0ec646eda 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -21,7 +21,6 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -32,644 +31,649 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The videos controller. +/// </summary> +public class VideosController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IDlnaManager _dlnaManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly IHttpClientFactory _httpClientFactory; + private readonly EncodingHelper _encodingHelper; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; + + /// <summary> + /// Initializes a new instance of the <see cref="VideosController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + public VideosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory, + EncodingHelper encodingHelper) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _dlnaManager = dlnaManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + _encodingHelper = encodingHelper; + } + /// <summary> - /// The videos controller. + /// Gets additional parts for a video. /// </summary> - public class VideosController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Additional parts returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns> + [HttpGet("{itemId}/AdditionalParts")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IDlnaManager _dlnaManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly IHttpClientFactory _httpClientFactory; - private readonly EncodingHelper _encodingHelper; - - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; - - /// <summary> - /// Initializes a new instance of the <see cref="VideosController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - public VideosController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - IHttpClientFactory httpClientFactory, - EncodingHelper encodingHelper) + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var item = itemId.Equals(default) + ? (userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); + + var dtoOptions = new DtoOptions(); + dtoOptions = dtoOptions.AddClientFields(User); + + BaseItemDto[] items; + if (item is Video video) { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _dlnaManager = dlnaManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _httpClientFactory = httpClientFactory; - _encodingHelper = encodingHelper; + items = video.GetAdditionalParts() + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) + .ToArray(); } - - /// <summary> - /// Gets additional parts for a video. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Additional parts returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns> - [HttpGet("{itemId}/AdditionalParts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) + else { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + items = Array.Empty<BaseItemDto>(); + } - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); + var result = new QueryResult<BaseItemDto>(items); + return result; + } - var dtoOptions = new DtoOptions(); - dtoOptions = dtoOptions.AddClientFields(User); + /// <summary> + /// Removes alternate video sources. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Alternate sources deleted.</response> + /// <response code="404">Video not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns> + [HttpDelete("{itemId}/AlternateSources")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId) + { + var video = (Video)_libraryManager.GetItemById(itemId); - BaseItemDto[] items; - if (item is Video video) - { - items = video.GetAdditionalParts() - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) - .ToArray(); - } - else - { - items = Array.Empty<BaseItemDto>(); - } + if (video is null) + { + return NotFound("The video either does not exist or the id does not belong to a video."); + } - var result = new QueryResult<BaseItemDto>(items); - return result; + if (video.LinkedAlternateVersions.Length == 0) + { + video = (Video?)_libraryManager.GetItemById(video.PrimaryVersionId); } - /// <summary> - /// Removes alternate video sources. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="204">Alternate sources deleted.</response> - /// <response code="404">Video not found.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns> - [HttpDelete("{itemId}/AlternateSources")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId) + if (video is null) { - var video = (Video)_libraryManager.GetItemById(itemId); + return NotFound(); + } - if (video is null) - { - return NotFound("The video either does not exist or the id does not belong to a video."); - } + foreach (var link in video.GetLinkedAlternateVersions()) + { + link.SetPrimaryVersionId(null); + link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - if (video.LinkedAlternateVersions.Length == 0) - { - video = (Video)_libraryManager.GetItemById(video.PrimaryVersionId); - } + await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } - foreach (var link in video.GetLinkedAlternateVersions()) - { - link.SetPrimaryVersionId(null); - link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.SetPrimaryVersionId(null); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - } + return NoContent(); + } - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - video.SetPrimaryVersionId(null); - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + /// <summary> + /// Merges videos into a single record. + /// </summary> + /// <param name="ids">Item id list. This allows multiple, comma delimited.</param> + /// <response code="204">Videos merged.</response> + /// <response code="400">Supply at least 2 video ids.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> + [HttpPost("MergeVersions")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + var items = ids + .Select(i => _libraryManager.GetItemById(i)) + .OfType<Video>() + .OrderBy(i => i.Id) + .ToList(); - return NoContent(); + if (items.Count < 2) + { + return BadRequest("Please supply at least two videos to merge."); } - /// <summary> - /// Merges videos into a single record. - /// </summary> - /// <param name="ids">Item id list. This allows multiple, comma delimited.</param> - /// <response code="204">Videos merged.</response> - /// <response code="400">Supply at least 2 video ids.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> - [HttpPost("MergeVersions")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); + if (primaryVersion is null) { - var items = ids - .Select(i => _libraryManager.GetItemById(i)) - .OfType<Video>() - .OrderBy(i => i.Id) - .ToList(); - - if (items.Count < 2) - { - return BadRequest("Please supply at least two videos to merge."); - } - - var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); - if (primaryVersion is null) - { - primaryVersion = items - .OrderBy(i => + primaryVersion = items + .OrderBy(i => + { + if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) { - if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) - { - return 1; - } - - return 0; - }) - .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) - .First(); - } + return 1; + } - var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); + return 0; + }) + .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) + .First(); + } - foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id))) - { - item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); + var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); - await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id))) + { + item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase))) - { - alternateVersionsOfPrimary.Add(new LinkedChild - { - Path = item.Path, - ItemId = item.Id - }); - } + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - foreach (var linkedItem in item.LinkedAlternateVersions) + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase))) + { + alternateVersionsOfPrimary.Add(new LinkedChild { - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) - { - alternateVersionsOfPrimary.Add(linkedItem); - } - } + Path = item.Path, + ItemId = item.Id + }); + } - if (item.LinkedAlternateVersions.Length > 0) + foreach (var linkedItem in item.LinkedAlternateVersions) + { + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) { - item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + alternateVersionsOfPrimary.Add(linkedItem); } } - primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray(); - await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - return NoContent(); + if (item.LinkedAlternateVersions.Length > 0) + { + item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } } - /// <summary> - /// Gets a video stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream")] - [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesVideoFile] - public async Task<ActionResult> GetVideoStream( - [FromRoute, Required] Guid itemId, - [FromQuery] string? container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray(); + await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Gets a video stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream")] + [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] + public async Task<ActionResult> GetVideoStream( + [FromRoute, Required] Guid itemId, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new VideoRequestDto { - var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - var streamingRequest = new VideoRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; - - var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - _transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null) - { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); - var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); - if (liveStreamInfo is null) - { - return NotFound(); - } + if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); - var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); - // TODO (moved from MediaBrowser.Api): Don't hardcode contentType - return File(liveStream, MimeTypes.GetMimeType("file.ts")); + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); + if (liveStreamInfo is null) + { + return NotFound(); } - // Static remote stream - if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) - { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return File(liveStream, MimeTypes.GetMimeType("file.ts")); + } - var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); - return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false); - } + // Static remote stream + if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); - if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) - { - return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); - } + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false); + } - var outputPath = state.OutputFilePath; - var outputPathExists = System.IO.File.Exists(outputPath); + if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) + { + return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); + } - var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); - var isTranscodeCached = outputPathExists && transcodingJob is not null; + var outputPath = state.OutputFilePath; + var outputPathExists = System.IO.File.Exists(outputPath); - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager); + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob is not null; - // Static stream - if (@static.HasValue && @static.Value) - { - var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager); - if (state.MediaSource.IsInfiniteStream) - { - var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); - return File(liveStream, contentType); - } + // Static stream + if (@static.HasValue && @static.Value) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); - return FileStreamResponseHelpers.GetStaticFileResult( - state.MediaPath, - contentType); + if (state.MediaSource.IsInfiniteStream) + { + var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + return File(liveStream, contentType); } - // Need to start ffmpeg (because media can't be returned directly) - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); - return await FileStreamResponseHelpers.GetTranscodedFile( - state, - isHeadRequest, - HttpContext, - _transcodingJobHelper, - ffmpegCommandLineArguments, - _transcodingJobType, - cancellationTokenSource).ConfigureAwait(false); + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType); } - /// <summary> - /// Gets a video stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream.{container}")] - [HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesVideoFile] - public Task<ActionResult> GetVideoStreamByContainer( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) - { - return GetVideoStream( - itemId, - container, - @static, - @params, - tag, - deviceProfileId, - playSessionId, - segmentContainer, - segmentLength, - minSegments, - mediaSourceId, - deviceId, - audioCodec, - enableAutoStreamCopy, - allowVideoStreamCopy, - allowAudioStreamCopy, - breakOnNonKeyFrames, - audioSampleRate, - maxAudioBitDepth, - audioBitRate, - audioChannels, - maxAudioChannels, - profile, - level, - framerate, - maxFramerate, - copyTimestamps, - startTimeTicks, - width, - height, - maxWidth, - maxHeight, - videoBitRate, - subtitleStreamIndex, - subtitleMethod, - maxRefFrames, - maxVideoBitDepth, - requireAvc, - deInterlace, - requireNonAnamorphic, - transcodingMaxAudioChannels, - cpuCoreLimit, - liveStreamId, - enableMpegtsM2TsMode, - videoCodec, - subtitleCodec, - transcodeReasons, - audioStreamIndex, - videoStreamIndex, - context, - streamOptions); - } + // Need to start ffmpeg (because media can't be returned directly) + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + HttpContext, + _transcodingJobHelper, + ffmpegCommandLineArguments, + _transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + + /// <summary> + /// Gets a video stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream.{container}")] + [HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] + public Task<ActionResult> GetVideoStreamByContainer( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + return GetVideoStream( + itemId, + container, + @static, + @params, + tag, + deviceProfileId, + playSessionId, + segmentContainer, + segmentLength, + minSegments, + mediaSourceId, + deviceId, + audioCodec, + enableAutoStreamCopy, + allowVideoStreamCopy, + allowAudioStreamCopy, + breakOnNonKeyFrames, + audioSampleRate, + maxAudioBitDepth, + audioBitRate, + audioChannels, + maxAudioChannels, + profile, + level, + framerate, + maxFramerate, + copyTimestamps, + startTimeTicks, + width, + height, + maxWidth, + maxHeight, + videoBitRate, + subtitleStreamIndex, + subtitleMethod, + maxRefFrames, + maxVideoBitDepth, + requireAvc, + deInterlace, + requireNonAnamorphic, + transcodingMaxAudioChannels, + cpuCoreLimit, + liveStreamId, + enableMpegtsM2TsMode, + videoCodec, + subtitleCodec, + transcodeReasons, + audioStreamIndex, + videoStreamIndex, + context, + streamOptions); } } diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index cd85ba221b..74370db50b 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -19,208 +18,209 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Years controller. +/// </summary> +[Authorize] +public class YearsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// <summary> - /// Years controller. + /// Initializes a new instance of the <see cref="YearsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class YearsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public YearsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="YearsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public YearsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// <summary> - /// Get years. - /// </summary> - /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param> - /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User Id.</param> - /// <param name="recursive">Search recursively.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <response code="200">Year query returned.</response> - /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetYears( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] bool recursive = true, - [FromQuery] bool? enableImages = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + /// <summary> + /// Get years. + /// </summary> + /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User Id.</param> + /// <param name="recursive">Search recursively.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <response code="200">Year query returned.</response> + /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetYears( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] bool recursive = true, + [FromQuery] bool? enableImages = true) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); + User? user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - var query = new InternalItemsQuery(user) - { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - DtoOptions = dtoOptions - }; + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, + DtoOptions = dtoOptions + }; + + bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); - bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); + IList<BaseItem> items; + if (parentItem.IsFolder) + { + var folder = (Folder)parentItem; - IList<BaseItem> items; - if (parentItem.IsFolder) + if (userId.Equals(default)) { - var folder = (Folder)parentItem; - - if (userId.Equals(default)) - { - items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList(); - } - else - { - items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList(); - } + items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList(); } else { - items = new[] { parentItem }.Where(Filter).ToList(); + items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList(); } + } + else + { + items = new[] { parentItem }.Where(Filter).ToList(); + } - var extractedItems = GetAllItems(items); + var extractedItems = GetAllItems(items); - var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder)); + var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder)); - var ibnItemsArray = filteredItems.ToList(); + var ibnItemsArray = filteredItems.ToList(); - IEnumerable<BaseItem> ibnItems = ibnItemsArray; + IEnumerable<BaseItem> ibnItems = ibnItemsArray; - if (startIndex.HasValue || limit.HasValue) + if (startIndex.HasValue || limit.HasValue) + { + if (startIndex.HasValue) { - if (startIndex.HasValue) - { - ibnItems = ibnItems.Skip(startIndex.Value); - } - - if (limit.HasValue) - { - ibnItems = ibnItems.Take(limit.Value); - } + ibnItems = ibnItems.Skip(startIndex.Value); } - var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>())); - - var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user)); - - var result = new QueryResult<BaseItemDto>( - startIndex, - ibnItemsArray.Count, - dtos.Where(i => i is not null).ToArray()); - return result; - } - - /// <summary> - /// Gets a year. - /// </summary> - /// <param name="year">The year.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Year returned.</response> - /// <response code="404">Year not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the year, - /// or a <see cref="NotFoundResult"/> if year not found. - /// </returns> - [HttpGet("{year}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId) - { - var item = _libraryManager.GetYear(year); - if (item is null) + if (limit.HasValue) { - return NotFound(); + ibnItems = ibnItems.Take(limit.Value); } + } - var dtoOptions = new DtoOptions() - .AddClientFields(User); + var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>())); - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user)); + + var result = new QueryResult<BaseItemDto>( + startIndex, + ibnItemsArray.Count, + dtos.Where(i => i is not null).ToArray()); + return result; + } - return _dtoService.GetBaseItemDto(item, dtoOptions); + /// <summary> + /// Gets a year. + /// </summary> + /// <param name="year">The year.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Year returned.</response> + /// <response code="404">Year not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the year, + /// or a <see cref="NotFoundResult"/> if year not found. + /// </returns> + [HttpGet("{year}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var item = _libraryManager.GetYear(year); + if (item is null) + { + return NotFound(); } - private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<string> mediaTypes) + var dtoOptions = new DtoOptions() + .AddClientFields(User); + + if (!userId.Value.Equals(default)) { - var baseItemKind = f.GetBaseItemKind(); - // Exclude item types - if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind)) - { - return false; - } + var user = _userManager.GetUserById(userId.Value); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - // Include item types - if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind)) - { - return false; - } + return _dtoService.GetBaseItemDto(item, dtoOptions); + } - // Include MediaTypes - if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - return false; - } + private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<string> mediaTypes) + { + var baseItemKind = f.GetBaseItemKind(); + // Exclude item types + if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind)) + { + return false; + } - return true; + // Include item types + if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind)) + { + return false; } - private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items) + // Include MediaTypes + if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { - return items - .Select(i => i.ProductionYear ?? 0) - .Where(i => i > 0) - .Distinct() - .Select(year => _libraryManager.GetYear(year)); + return false; } + + return true; + } + + private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items) + { + return items + .Select(i => i.ProductionYear ?? 0) + .Where(i => i > 0) + .Distinct() + .Select(year => _libraryManager.GetYear(year)); } } diff --git a/Jellyfin.Api/Extensions/ClaimsPrincipalExtensions.cs b/Jellyfin.Api/Extensions/ClaimsPrincipalExtensions.cs index 6b3e78d4d1..d2e8eb378b 100644 --- a/Jellyfin.Api/Extensions/ClaimsPrincipalExtensions.cs +++ b/Jellyfin.Api/Extensions/ClaimsPrincipalExtensions.cs @@ -71,8 +71,7 @@ public static class ClaimsPrincipalExtensions public static bool GetIsApiKey(this ClaimsPrincipal user) { var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey); - return !string.IsNullOrEmpty(claimValue) - && bool.TryParse(claimValue, out var parsedClaimValue) + return bool.TryParse(claimValue, out var parsedClaimValue) && parsedClaimValue; } diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 9e784f7c45..2d7a56d913 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -5,112 +5,110 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; -using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Extensions +namespace Jellyfin.Api.Extensions; + +/// <summary> +/// Dto Extensions. +/// </summary> +public static class DtoExtensions { /// <summary> - /// Dto Extensions. + /// Add additional fields depending on client. /// </summary> - public static class DtoExtensions + /// <remarks> + /// Use in place of GetDtoOptions. + /// Legacy order: 2. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="user">Current claims principal.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddClientFields( + this DtoOptions dtoOptions, ClaimsPrincipal user) { - /// <summary> - /// Add additional fields depending on client. - /// </summary> - /// <remarks> - /// Use in place of GetDtoOptions. - /// Legacy order: 2. - /// </remarks> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="user">Current claims principal.</param> - /// <returns>Modified DtoOptions object.</returns> - internal static DtoOptions AddClientFields( - this DtoOptions dtoOptions, ClaimsPrincipal user) - { - dtoOptions.Fields ??= Array.Empty<ItemFields>(); + dtoOptions.Fields ??= Array.Empty<ItemFields>(); - string? client = user.GetClient(); + string? client = user.GetClient(); - // No client in claim - if (string.IsNullOrEmpty(client)) - { - return dtoOptions; - } + // No client in claim + if (string.IsNullOrEmpty(client)) + { + return dtoOptions; + } - if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) + if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) { - if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) - { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.RecursiveItemCount; - dtoOptions.Fields = arr; - } + int oldLen = dtoOptions.Fields.Count; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.RecursiveItemCount; + dtoOptions.Fields = arr; } + } - if (!dtoOptions.ContainsField(ItemFields.ChildCount)) + if (!dtoOptions.ContainsField(ItemFields.ChildCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) { - if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) - { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.ChildCount; - dtoOptions.Fields = arr; - } + int oldLen = dtoOptions.Fields.Count; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.ChildCount; + dtoOptions.Fields = arr; } - - return dtoOptions; } - /// <summary> - /// Add additional DtoOptions. - /// </summary> - /// <remarks> - /// Converted from IHasDtoOptions. - /// Legacy order: 3. - /// </remarks> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="enableImages">Enable images.</param> - /// <param name="enableUserData">Enable user data.</param> - /// <param name="imageTypeLimit">Image type limit.</param> - /// <param name="enableImageTypes">Enable image types.</param> - /// <returns>Modified DtoOptions object.</returns> - internal static DtoOptions AddAdditionalDtoOptions( - this DtoOptions dtoOptions, - bool? enableImages, - bool? enableUserData, - int? imageTypeLimit, - IReadOnlyList<ImageType> enableImageTypes) - { - dtoOptions.EnableImages = enableImages ?? true; + return dtoOptions; + } - if (imageTypeLimit.HasValue) - { - dtoOptions.ImageTypeLimit = imageTypeLimit.Value; - } + /// <summary> + /// Add additional DtoOptions. + /// </summary> + /// <remarks> + /// Converted from IHasDtoOptions. + /// Legacy order: 3. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="enableImages">Enable images.</param> + /// <param name="enableUserData">Enable user data.</param> + /// <param name="imageTypeLimit">Image type limit.</param> + /// <param name="enableImageTypes">Enable image types.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddAdditionalDtoOptions( + this DtoOptions dtoOptions, + bool? enableImages, + bool? enableUserData, + int? imageTypeLimit, + IReadOnlyList<ImageType> enableImageTypes) + { + dtoOptions.EnableImages = enableImages ?? true; - if (enableUserData.HasValue) - { - dtoOptions.EnableUserData = enableUserData.Value; - } + if (imageTypeLimit.HasValue) + { + dtoOptions.ImageTypeLimit = imageTypeLimit.Value; + } - if (enableImageTypes.Count != 0) - { - dtoOptions.ImageTypes = enableImageTypes; - } + if (enableUserData.HasValue) + { + dtoOptions.EnableUserData = enableUserData.Value; + } - return dtoOptions; + if (enableImageTypes.Count != 0) + { + dtoOptions.ImageTypes = enableImageTypes; } + + return dtoOptions; } } diff --git a/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs index 8f1f5dd940..96b29b1cbd 100644 --- a/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs @@ -2,20 +2,19 @@ using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Camel Case Json Profile Formatter. +/// </summary> +public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter { /// <summary> - /// Camel Case Json Profile Formatter. + /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class. /// </summary> - public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions) { - /// <summary> - /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class. - /// </summary> - public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions) - { - SupportedMediaTypes.Clear(); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType)); - } + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType)); } } diff --git a/Jellyfin.Api/Formatters/CssOutputFormatter.cs b/Jellyfin.Api/Formatters/CssOutputFormatter.cs index e88c8ad1b2..0a38911387 100644 --- a/Jellyfin.Api/Formatters/CssOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/CssOutputFormatter.cs @@ -3,34 +3,33 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Css output formatter. +/// </summary> +public class CssOutputFormatter : TextOutputFormatter { /// <summary> - /// Css output formatter. + /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. /// </summary> - public class CssOutputFormatter : TextOutputFormatter + public CssOutputFormatter() { - /// <summary> - /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. - /// </summary> - public CssOutputFormatter() - { - SupportedMediaTypes.Add("text/css"); + SupportedMediaTypes.Add("text/css"); - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } - /// <summary> - /// Write context object to stream. - /// </summary> - /// <param name="context">Writer context.</param> - /// <param name="selectedEncoding">Unused. Writer encoding.</param> - /// <returns>Write stream task.</returns> - public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) - { - var stringResponse = context.Object?.ToString(); - return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); - } + /// <summary> + /// Write context object to stream. + /// </summary> + /// <param name="context">Writer context.</param> + /// <param name="selectedEncoding">Unused. Writer encoding.</param> + /// <returns>Write stream task.</returns> + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + var stringResponse = context.Object?.ToString(); + return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } diff --git a/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs index 5d77dbf4cc..b5b5752785 100644 --- a/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs @@ -3,22 +3,21 @@ using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Pascal Case Json Profile Formatter. +/// </summary> +public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter { /// <summary> - /// Pascal Case Json Profile Formatter. + /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class. /// </summary> - public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions) { - /// <summary> - /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class. - /// </summary> - public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions) - { - SupportedMediaTypes.Clear(); - // Add application/json for default formatter - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json)); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType)); - } + SupportedMediaTypes.Clear(); + // Add application/json for default formatter + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json)); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType)); } } diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs index df8b1650be..d5dea0f097 100644 --- a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs @@ -4,30 +4,29 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Xml output formatter. +/// </summary> +public class XmlOutputFormatter : TextOutputFormatter { /// <summary> - /// Xml output formatter. + /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. /// </summary> - public class XmlOutputFormatter : TextOutputFormatter + public XmlOutputFormatter() { - /// <summary> - /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. - /// </summary> - public XmlOutputFormatter() - { - SupportedMediaTypes.Clear(); - SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } - /// <inheritdoc /> - public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) - { - var stringResponse = context.Object?.ToString(); - return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); - } + /// <inheritdoc /> + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + var stringResponse = context.Object?.ToString(); + return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index be410ebcd6..2b18c389d7 100644 --- a/Jellyfin.Api/Helpers/AudioHelper.cs +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -16,165 +16,164 @@ using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Audio helper. +/// </summary> +public class AudioHelper { + private readonly IDlnaManager _dlnaManager; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly EncodingHelper _encodingHelper; + /// <summary> - /// Audio helper. + /// Initializes a new instance of the <see cref="AudioHelper"/> class. /// </summary> - public class AudioHelper + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + public AudioHelper( + IDlnaManager dlnaManager, + IUserManager userManager, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + EncodingHelper encodingHelper) { - private readonly IDlnaManager _dlnaManager; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly EncodingHelper _encodingHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="AudioHelper"/> class. - /// </summary> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - public AudioHelper( - IDlnaManager dlnaManager, - IUserManager userManager, - ILibraryManager libraryManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - IHttpClientFactory httpClientFactory, - IHttpContextAccessor httpContextAccessor, - EncodingHelper encodingHelper) + _dlnaManager = dlnaManager; + _userManager = userManager; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + _httpContextAccessor = httpContextAccessor; + _encodingHelper = encodingHelper; + } + + /// <summary> + /// Get audio stream. + /// </summary> + /// <param name="transcodingJobType">Transcoding job type.</param> + /// <param name="streamingRequest">Streaming controller.Request dto.</param> + /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> + public async Task<ActionResult> GetAudioStream( + TranscodingJobType transcodingJobType, + StreamingRequestDto streamingRequest) + { + if (_httpContextAccessor.HttpContext is null) { - _dlnaManager = dlnaManager; - _userManager = userManager; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _httpClientFactory = httpClientFactory; - _httpContextAccessor = httpContextAccessor; - _encodingHelper = encodingHelper; + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); } - /// <summary> - /// Get audio stream. - /// </summary> - /// <param name="transcodingJobType">Transcoding job type.</param> - /// <param name="streamingRequest">Streaming controller.Request dto.</param> - /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> - public async Task<ActionResult> GetAudioStream( - TranscodingJobType transcodingJobType, - StreamingRequestDto streamingRequest) - { - if (_httpContextAccessor.HttpContext is null) - { - throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); - } + bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; - bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; - - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - _httpContextAccessor.HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - if (streamingRequest.Static && state.DirectStreamProvider is not null) - { - StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); - var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); - if (liveStreamInfo is null) - { - throw new FileNotFoundException(); - } + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + _httpContextAccessor.HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); - var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); - // TODO (moved from MediaBrowser.Api): Don't hardcode contentType - return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts")); - } + if (streamingRequest.Static && state.DirectStreamProvider is not null) + { + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); - // Static remote stream - if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http) + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); + if (liveStreamInfo is null) { - StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); - - var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); - return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false); + throw new FileNotFoundException(); } - if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File) - { - return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically"); - } + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts")); + } - var outputPath = state.OutputFilePath; - var outputPathExists = File.Exists(outputPath); + // Static remote stream + if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); - var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); - var isTranscodeCached = outputPathExists && transcodingJob is not null; + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false); + } - StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File) + { + return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically"); + } - // Static stream - if (streamingRequest.Static) - { - var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + var outputPath = state.OutputFilePath; + var outputPathExists = File.Exists(outputPath); - if (state.MediaSource.IsInfiniteStream) - { - var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); - return new FileStreamResult(stream, contentType); - } + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob is not null; - return FileStreamResponseHelpers.GetStaticFileResult( - state.MediaPath, - contentType); + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + + // Static stream + if (streamingRequest.Static) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + + if (state.MediaSource.IsInfiniteStream) + { + var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + return new FileStreamResult(stream, contentType); } - // Need to start ffmpeg (because media can't be returned directly) - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); - return await FileStreamResponseHelpers.GetTranscodedFile( - state, - isHeadRequest, - _httpContextAccessor.HttpContext, - _transcodingJobHelper, - ffmpegCommandLineArguments, - transcodingJobType, - cancellationTokenSource).ConfigureAwait(false); + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType); } + + // Need to start ffmpeg (because media can't be returned directly) + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + _httpContextAccessor.HttpContext, + _transcodingJobHelper, + ffmpegCommandLineArguments, + transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 4a338efff2..646bf6443c 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -9,6 +9,8 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -25,725 +27,756 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Dynamic hls helper. +/// </summary> +public class DynamicHlsHelper { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDlnaManager _dlnaManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly INetworkManager _networkManager; + private readonly ILogger<DynamicHlsHelper> _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly EncodingHelper _encodingHelper; + /// <summary> - /// Dynamic hls helper. + /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class. /// </summary> - public class DynamicHlsHelper + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + public DynamicHlsHelper( + ILibraryManager libraryManager, + IUserManager userManager, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + INetworkManager networkManager, + ILogger<DynamicHlsHelper> logger, + IHttpContextAccessor httpContextAccessor, + EncodingHelper encodingHelper) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dlnaManager = dlnaManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _networkManager = networkManager; + _logger = logger; + _httpContextAccessor = httpContextAccessor; + _encodingHelper = encodingHelper; + } + + /// <summary> + /// Get master hls playlist. + /// </summary> + /// <param name="transcodingJobType">Transcoding job type.</param> + /// <param name="streamingRequest">Streaming request dto.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> + public async Task<ActionResult> GetMasterHlsPlaylist( + TranscodingJobType transcodingJobType, + StreamingRequestDto streamingRequest, + bool enableAdaptiveBitrateStreaming) + { + var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head; + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + return await GetMasterPlaylistInternal( + streamingRequest, + isHeadRequest, + enableAdaptiveBitrateStreaming, + transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + + private async Task<ActionResult> GetMasterPlaylistInternal( + StreamingRequestDto streamingRequest, + bool isHeadRequest, + bool enableAdaptiveBitrateStreaming, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDlnaManager _dlnaManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly INetworkManager _networkManager; - private readonly ILogger<DynamicHlsHelper> _logger; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly EncodingHelper _encodingHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - public DynamicHlsHelper( - ILibraryManager libraryManager, - IUserManager userManager, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - INetworkManager networkManager, - ILogger<DynamicHlsHelper> logger, - IHttpContextAccessor httpContextAccessor, - EncodingHelper encodingHelper) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dlnaManager = dlnaManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _networkManager = networkManager; - _logger = logger; - _httpContextAccessor = httpContextAccessor; - _encodingHelper = encodingHelper; - } - - /// <summary> - /// Get master hls playlist. - /// </summary> - /// <param name="transcodingJobType">Transcoding job type.</param> - /// <param name="streamingRequest">Streaming request dto.</param> - /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> - /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> - public async Task<ActionResult> GetMasterHlsPlaylist( - TranscodingJobType transcodingJobType, - StreamingRequestDto streamingRequest, - bool enableAdaptiveBitrateStreaming) - { - var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head; - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - return await GetMasterPlaylistInternal( + if (_httpContextAccessor.HttpContext is null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + + using var state = await StreamingHelpers.GetStreamingState( streamingRequest, - isHeadRequest, - enableAdaptiveBitrateStreaming, + _httpContextAccessor.HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, transcodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } + cancellationTokenSource.Token) + .ConfigureAwait(false); - private async Task<ActionResult> GetMasterPlaylistInternal( - StreamingRequestDto streamingRequest, - bool isHeadRequest, - bool enableAdaptiveBitrateStreaming, - TranscodingJobType transcodingJobType, - CancellationTokenSource cancellationTokenSource) + _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0"); + if (isHeadRequest) { - if (_httpContextAccessor.HttpContext is null) - { - throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); - } + return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8")); + } - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - _httpContextAccessor.HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0"); - if (isHeadRequest) - { - return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8")); - } + var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0); - var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0); + var builder = new StringBuilder(); - var builder = new StringBuilder(); + builder.AppendLine("#EXTM3U"); - builder.AppendLine("#EXTM3U"); + var isLiveStream = state.IsSegmentedLiveStream; - var isLiveStream = state.IsSegmentedLiveStream; + var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); - var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); + // from universal audio service + if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer) + && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase)) + { + queryString += "&SegmentContainer=" + state.Request.SegmentContainer; + } - // from universal audio service - if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer) - && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase)) - { - queryString += "&SegmentContainer=" + state.Request.SegmentContainer; - } + // from universal audio service + if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) + && !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase)) + { + queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; + } - // from universal audio service - if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) - && !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase)) - { - queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; - } + // Main stream + var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; - // Main stream - var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; + playlistUrl += queryString; - playlistUrl += queryString; + var subtitleStreams = state.MediaSource + .MediaStreams + .Where(i => i.IsTextSubtitleStream) + .ToList(); - var subtitleStreams = state.MediaSource - .MediaStreams - .Where(i => i.IsTextSubtitleStream) - .ToList(); + var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest) + ? "subs" + : null; - var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest) - ? "subs" - : null; + // If we're burning in subtitles then don't add additional subs to the manifest + if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + { + subtitleGroup = null; + } - // If we're burning in subtitles then don't add additional subs to the manifest - if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) - { - subtitleGroup = null; - } + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); + } + + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); - if (!string.IsNullOrWhiteSpace(subtitleGroup)) + if (state.VideoStream is not null && state.VideoRequest is not null) + { + // Provide a workaround for the case issue between flac and fLaC. + var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString()); + if (!string.IsNullOrEmpty(flacWaPlaylist)) { - AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); + builder.Append(flacWaPlaylist); } - var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - if (state.VideoStream is not null && state.VideoRequest is not null) + // Provide SDR HEVC entrance for backward compatibility. + if (encodingOptions.AllowHevcEncoding + && !encodingOptions.AllowAv1Encoding + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.VideoRange == VideoRange.HDR + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - // Provide a workaround for the case issue between flac and fLaC. - var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString()); - if (!string.IsNullOrEmpty(flacWaPlaylist)) + var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); + if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) { - builder.Append(flacWaPlaylist); - } - - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - - // Provide SDR HEVC entrance for backward compatibility. - if (encodingOptions.AllowHevcEncoding - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && !string.IsNullOrEmpty(state.VideoStream.VideoRange) - && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) - && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); - if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) + // Force HEVC Main Profile and disable video stream copy. + state.OutputVideoCodec = "hevc"; + var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); + sdrVideoUrl += "&AllowVideoStreamCopy=false"; + + var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + var sdrOutputAudioBitrate = 0; + if (EncodingHelper.LosslessAudioCodecs.Contains(state.VideoRequest.AudioCodec, StringComparison.OrdinalIgnoreCase)) { - // Force HEVC Main Profile and disable video stream copy. - state.OutputVideoCodec = "hevc"; - var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); - sdrVideoUrl += "&AllowVideoStreamCopy=false"; - - var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - var sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; - var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; - - var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); - - // Provide a workaround for the case issue between flac and fLaC. - flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString()); - if (!string.IsNullOrEmpty(flacWaPlaylist)) - { - builder.Append(flacWaPlaylist); - } - - // Restore the video codec - state.OutputVideoCodec = "copy"; + sdrOutputAudioBitrate = state.AudioStream.BitRate ?? 0; + } + else + { + sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream, state.OutputAudioChannels) ?? 0; } - } - - // Provide Level 5.0 entrance for backward compatibility. - // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, - // but in fact it is capable of playing videos up to Level 6.1. - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream.Level.HasValue - && state.VideoStream.Level > 150 - && !string.IsNullOrEmpty(state.VideoStream.VideoRange) - && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase) - && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - var playlistCodecsField = new StringBuilder(); - AppendPlaylistCodecsField(playlistCodecsField, state); - - // Force the video level to 5.0. - var originalLevel = state.VideoStream.Level; - state.VideoStream.Level = 150; - var newPlaylistCodecsField = new StringBuilder(); - AppendPlaylistCodecsField(newPlaylistCodecsField, state); - // Restore the video level. - state.VideoStream.Level = originalLevel; - var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); - builder.Append(newPlaylist); + var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; + var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); // Provide a workaround for the case issue between flac and fLaC. - flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist); + flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString()); if (!string.IsNullOrEmpty(flacWaPlaylist)) { builder.Append(flacWaPlaylist); } + + // Restore the video codec + state.OutputVideoCodec = "copy"; } } - if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) + // Provide Level 5.0 entrance for backward compatibility. + // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, + // but in fact it is capable of playing videos up to Level 6.1. + if (encodingOptions.AllowHevcEncoding + && !encodingOptions.AllowAv1Encoding + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.Level.HasValue + && state.VideoStream.Level > 150 + && state.VideoStream.VideoRange == VideoRange.SDR + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0; + var playlistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(playlistCodecsField, state); - // By default, vary by just 200k - var variation = GetBitrateVariation(totalBitrate); + // Force the video level to 5.0. + var originalLevel = state.VideoStream.Level; + state.VideoStream.Level = 150; + var newPlaylistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(newPlaylistCodecsField, state); - var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + // Restore the video level. + state.VideoStream.Level = originalLevel; + var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); + builder.Append(newPlaylist); - variation *= 2; - newBitrate = totalBitrate - variation; - variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + // Provide a workaround for the case issue between flac and fLaC. + flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist); + if (!string.IsNullOrEmpty(flacWaPlaylist)) + { + builder.Append(flacWaPlaylist); + } } - - return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } - private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) { - var playlistBuilder = new StringBuilder(); - playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)) - .Append(",AVERAGE-BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)); + var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0; - AppendPlaylistVideoRangeField(playlistBuilder, state); + // By default, vary by just 200k + var variation = GetBitrateVariation(totalBitrate); - AppendPlaylistCodecsField(playlistBuilder, state); + var newBitrate = totalBitrate - variation; + var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); - AppendPlaylistResolutionField(playlistBuilder, state); + variation *= 2; + newBitrate = totalBitrate - variation; + variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + } - AppendPlaylistFramerateField(playlistBuilder, state); + return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } - if (!string.IsNullOrWhiteSpace(subtitleGroup)) - { - playlistBuilder.Append(",SUBTITLES=\"") - .Append(subtitleGroup) - .Append('"'); - } + private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + { + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)) + .Append(",AVERAGE-BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)); + + AppendPlaylistVideoRangeField(playlistBuilder, state); + + AppendPlaylistCodecsField(playlistBuilder, state); + + AppendPlaylistResolutionField(playlistBuilder, state); - playlistBuilder.Append(Environment.NewLine); - playlistBuilder.AppendLine(url); - builder.Append(playlistBuilder); + AppendPlaylistFramerateField(playlistBuilder, state); - return playlistBuilder; + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + playlistBuilder.Append(",SUBTITLES=\"") + .Append(subtitleGroup) + .Append('"'); } - /// <summary> - /// Appends a VIDEO-RANGE field containing the range of the output video stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state) + playlistBuilder.Append(Environment.NewLine); + playlistBuilder.AppendLine(url); + builder.Append(playlistBuilder); + + return playlistBuilder; + } + + /// <summary> + /// Appends a VIDEO-RANGE field containing the range of the output video stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state) + { + if (state.VideoStream is not null && state.VideoStream.VideoRange != VideoRange.Unknown) { - if (state.VideoStream is not null && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) + var videoRange = state.VideoStream.VideoRange; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { - var videoRange = state.VideoStream.VideoRange; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + if (videoRange == VideoRange.SDR) { - if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) - { - builder.Append(",VIDEO-RANGE=SDR"); - } - - if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) - { - builder.Append(",VIDEO-RANGE=PQ"); - } - } - else - { - // Currently we only encode to SDR. builder.Append(",VIDEO-RANGE=SDR"); } - } - } - /// <summary> - /// Appends a CODECS field containing formatted strings of - /// the active streams output video and audio codecs. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> - /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) - { - // Video - string videoCodecs = string.Empty; - int? videoCodecLevel = GetOutputVideoCodecLevel(state); - if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) - { - videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); + if (videoRange == VideoRange.HDR) + { + builder.Append(",VIDEO-RANGE=PQ"); + } } - - // Audio - string audioCodecs = string.Empty; - if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + else { - audioCodecs = GetPlaylistAudioCodecs(state); + // Currently we only encode to SDR. + builder.Append(",VIDEO-RANGE=SDR"); } + } + } - StringBuilder codecs = new StringBuilder(); + /// <summary> + /// Appends a CODECS field containing formatted strings of + /// the active streams output video and audio codecs. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> + /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) + { + // Video + string videoCodecs = string.Empty; + int? videoCodecLevel = GetOutputVideoCodecLevel(state); + if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) + { + videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); + } - codecs.Append(videoCodecs); + // Audio + string audioCodecs = string.Empty; + if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + { + audioCodecs = GetPlaylistAudioCodecs(state); + } - if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs)) - { - codecs.Append(','); - } + StringBuilder codecs = new StringBuilder(); - codecs.Append(audioCodecs); + codecs.Append(videoCodecs); - if (codecs.Length > 1) - { - builder.Append(",CODECS=\"") - .Append(codecs) - .Append('"'); - } + if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs)) + { + codecs.Append(','); } - /// <summary> - /// Appends a RESOLUTION field containing the resolution of the output stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) + codecs.Append(audioCodecs); + + if (codecs.Length > 1) { - if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) - { - builder.Append(",RESOLUTION=") - .Append(state.OutputWidth.GetValueOrDefault()) - .Append('x') - .Append(state.OutputHeight.GetValueOrDefault()); - } + builder.Append(",CODECS=\"") + .Append(codecs) + .Append('"'); } + } - /// <summary> - /// Appends a FRAME-RATE field containing the framerate of the output stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) + /// <summary> + /// Appends a RESOLUTION field containing the resolution of the output stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) + { + if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) { - double? framerate = null; - if (state.TargetFramerate.HasValue) - { - framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); - } - else if (state.VideoStream?.RealFrameRate is not null) - { - framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); - } + builder.Append(",RESOLUTION=") + .Append(state.OutputWidth.GetValueOrDefault()) + .Append('x') + .Append(state.OutputHeight.GetValueOrDefault()); + } + } - if (framerate.HasValue) - { - builder.Append(",FRAME-RATE=") - .Append(framerate.Value.ToString(CultureInfo.InvariantCulture)); - } + /// <summary> + /// Appends a FRAME-RATE field containing the framerate of the output stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) + { + double? framerate = null; + if (state.TargetFramerate.HasValue) + { + framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); + } + else if (state.VideoStream?.RealFrameRate is not null) + { + framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); } - private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress) + if (framerate.HasValue) { - // Within the local network this will likely do more harm than good. - if (_networkManager.IsInLocalNetwork(ipAddress)) - { - return false; - } + builder.Append(",FRAME-RATE=") + .Append(framerate.Value.ToString(CultureInfo.InvariantCulture)); + } + } - if (!enableAdaptiveBitrateStreaming) - { - return false; - } + private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress) + { + // Within the local network this will likely do more harm than good. + if (_networkManager.IsInLocalNetwork(ipAddress)) + { + return false; + } - if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) - { - // Opening live streams is so slow it's not even worth it - return false; - } + if (!enableAdaptiveBitrateStreaming) + { + return false; + } - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - return false; - } + if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) + { + // Opening live streams is so slow it's not even worth it + return false; + } - if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) - { - return false; - } + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + return false; + } - if (!state.IsOutputVideo) - { - return false; - } + if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) + { + return false; + } - // Having problems in android + if (!state.IsOutputVideo) + { return false; - // return state.VideoRequest.VideoBitRate.HasValue; } - private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user) + // Having problems in android + return false; + // return state.VideoRequest.VideoBitRate.HasValue; + } + + private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user) + { + if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop) { - if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop) - { - return; - } + return; + } - var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; - const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; + var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; + const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; - foreach (var stream in subtitles) - { - var name = stream.DisplayTitle; - - var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; - var isForced = stream.IsForced; - - var url = string.Format( - CultureInfo.InvariantCulture, - "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", - state.Request.MediaSourceId, - stream.Index.ToString(CultureInfo.InvariantCulture), - 30.ToString(CultureInfo.InvariantCulture), - user.GetToken()); - - var line = string.Format( - CultureInfo.InvariantCulture, - Format, - name, - isDefault ? "YES" : "NO", - isForced ? "YES" : "NO", - url, - stream.Language ?? "Unknown"); - - builder.AppendLine(line); - } + foreach (var stream in subtitles) + { + var name = stream.DisplayTitle; + + var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; + var isForced = stream.IsForced; + + var url = string.Format( + CultureInfo.InvariantCulture, + "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", + state.Request.MediaSourceId, + stream.Index.ToString(CultureInfo.InvariantCulture), + 30.ToString(CultureInfo.InvariantCulture), + user.GetToken()); + + var line = string.Format( + CultureInfo.InvariantCulture, + Format, + name, + isDefault ? "YES" : "NO", + isForced ? "YES" : "NO", + url, + stream.Language ?? "Unknown"); + + builder.AppendLine(line); } + } - /// <summary> - /// Get the H.26X level of the output video stream. - /// </summary> - /// <param name="state">StreamState of the current stream.</param> - /// <returns>H.26X level of the output video stream.</returns> - private int? GetOutputVideoCodecLevel(StreamState state) + /// <summary> + /// Get the H.26X level of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <returns>H.26X level of the output video stream.</returns> + private int? GetOutputVideoCodecLevel(StreamState state) + { + string levelString = string.Empty; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream is not null + && state.VideoStream.Level.HasValue) { - string levelString = string.Empty; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream is not null - && state.VideoStream.Level.HasValue) + levelString = state.VideoStream.Level.ToString() ?? string.Empty; + } + else + { + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) { - levelString = state.VideoStream.Level.ToString() ?? string.Empty; + levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); } - else - { - if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) - { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41"; - levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); - } - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; - levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); - } + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); } - if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) + if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)) { - return parsedLevel; + levelString = state.GetRequestedLevel("av1") ?? "19"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); } - - return null; } - /// <summary> - /// Get the H.26X profile of the output video stream. - /// </summary> - /// <param name="state">StreamState of the current stream.</param> - /// <param name="codec">Video codec.</param> - /// <returns>H.26X profile of the output video stream.</returns> - private string GetOutputVideoCodecProfile(StreamState state, string codec) + if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) { - string profileString = string.Empty; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && !string.IsNullOrEmpty(state.VideoStream.Profile)) - { - profileString = state.VideoStream.Profile; - } - else if (!string.IsNullOrEmpty(codec)) - { - profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; - if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) - { - profileString ??= "high"; - } + return parsedLevel; + } - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - profileString ??= "main"; - } - } + return null; + } - return profileString; + /// <summary> + /// Get the profile of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <returns>Profile of the output video stream.</returns> + private string GetOutputVideoCodecProfile(StreamState state, string codec) + { + string profileString = string.Empty; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.Profile)) + { + profileString = state.VideoStream.Profile; } - - /// <summary> - /// Gets a formatted string of the output audio codec, for use in the CODECS field. - /// </summary> - /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> - /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> - /// <param name="state">StreamState of the current stream.</param> - /// <returns>Formatted audio codec string.</returns> - private string GetPlaylistAudioCodecs(StreamState state) + else if (!string.IsNullOrEmpty(codec)) { - if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) + profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) { - string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); - return HlsCodecStringHelpers.GetAACString(profile); + profileString ??= "high"; } - if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)) { - return HlsCodecStringHelpers.GetMP3String(); + profileString ??= "main"; } + } - if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetAC3String(); - } + return profileString; + } - if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetEAC3String(); - } + /// <summary> + /// Gets a formatted string of the output audio codec, for use in the CODECS field. + /// </summary> + /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> + /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> + /// <param name="state">StreamState of the current stream.</param> + /// <returns>Formatted audio codec string.</returns> + private string GetPlaylistAudioCodecs(StreamState state) + { + if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) + { + string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); + return HlsCodecStringHelpers.GetAACString(profile); + } - if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetFLACString(); - } + if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetMP3String(); + } - if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetALACString(); - } + if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetAC3String(); + } - if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetOPUSString(); - } + if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetEAC3String(); + } - return string.Empty; + if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetFLACString(); } - /// <summary> - /// Gets a formatted string of the output video codec, for use in the CODECS field. - /// </summary> - /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> - /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> - /// <param name="state">StreamState of the current stream.</param> - /// <param name="codec">Video codec.</param> - /// <param name="level">Video level.</param> - /// <returns>Formatted video codec string.</returns> - private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) + if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) { - if (level == 0) - { - // This is 0 when there's no requested H.26X level in the device profile - // and the source is not encoded in H.26X - _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist"); - return string.Empty; - } + return HlsCodecStringHelpers.GetALACString(); + } - if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) - { - string profile = GetOutputVideoCodecProfile(state, "h264"); - return HlsCodecStringHelpers.GetH264String(profile, level); - } + if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetOPUSString(); + } - if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - string profile = GetOutputVideoCodecProfile(state, "hevc"); - return HlsCodecStringHelpers.GetH265String(profile, level); - } + return string.Empty; + } + /// <summary> + /// Gets a formatted string of the output video codec, for use in the CODECS field. + /// </summary> + /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> + /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <param name="level">Video level.</param> + /// <returns>Formatted video codec string.</returns> + private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) + { + if (level == 0) + { + // This is 0 when there's no requested level in the device profile + // and the source is not encoded in H.26X or AV1 + _logger.LogError("Got invalid level when building CODECS field for HLS master playlist"); return string.Empty; } - private int GetBitrateVariation(int bitrate) + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) { - // By default, vary by just 50k - var variation = 50000; + string profile = GetOutputVideoCodecProfile(state, "h264"); + return HlsCodecStringHelpers.GetH264String(profile, level); + } - if (bitrate >= 10000000) - { - variation = 2000000; - } - else if (bitrate >= 5000000) - { - variation = 1500000; - } - else if (bitrate >= 3000000) - { - variation = 1000000; - } - else if (bitrate >= 2000000) - { - variation = 500000; - } - else if (bitrate >= 1000000) - { - variation = 300000; - } - else if (bitrate >= 600000) - { - variation = 200000; - } - else if (bitrate >= 400000) + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + string profile = GetOutputVideoCodecProfile(state, "hevc"); + return HlsCodecStringHelpers.GetH265String(profile, level); + } + + if (string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase)) + { + string profile = GetOutputVideoCodecProfile(state, "av1"); + + // Currently we only transcode to 8 bits AV1 + int bitDepth = 8; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream != null + && state.VideoStream.BitDepth.HasValue) { - variation = 100000; + bitDepth = state.VideoStream.BitDepth.Value; } - return variation; + return HlsCodecStringHelpers.GetAv1String(profile, level, false, bitDepth); } - private string ReplaceVideoBitrate(string url, int oldValue, int newValue) + return string.Empty; + } + + private int GetBitrateVariation(int bitrate) + { + // By default, vary by just 50k + var variation = 50000; + + if (bitrate >= 10000000) { - return url.Replace( - "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), - "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), - StringComparison.OrdinalIgnoreCase); + variation = 2000000; } - - private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + else if (bitrate >= 5000000) { - string profileStr = codec + "-profile="; - return url.Replace( - profileStr + oldValue, - profileStr + newValue, - StringComparison.OrdinalIgnoreCase); + variation = 1500000; } - - private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + else if (bitrate >= 3000000) { - var oldPlaylist = playlist.ToString(); - return oldPlaylist.Replace( - oldValue.ToString(), - newValue.ToString(), - StringComparison.Ordinal); + variation = 1000000; } - - private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist) + else if (bitrate >= 2000000) { - if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) - { - return string.Empty; - } + variation = 500000; + } + else if (bitrate >= 1000000) + { + variation = 300000; + } + else if (bitrate >= 600000) + { + variation = 200000; + } + else if (bitrate >= 400000) + { + variation = 100000; + } - var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal); + return variation; + } + + private string ReplaceVideoBitrate(string url, int oldValue, int newValue) + { + return url.Replace( + "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), + "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + } + + private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + { + string profileStr = codec + "-profile="; + return url.Replace( + profileStr + oldValue, + profileStr + newValue, + StringComparison.OrdinalIgnoreCase); + } - return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty; + private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + { + var oldPlaylist = playlist.ToString(); + return oldPlaylist.Replace( + oldValue.ToString(), + newValue.ToString(), + StringComparison.Ordinal); + } + + private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist) + { + if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; } + + var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal); + + return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty; } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 5bdd3fe2e8..0f0a70c698 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -11,110 +11,109 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// The stream response helpers. +/// </summary> +public static class FileStreamResponseHelpers { /// <summary> - /// The stream response helpers. + /// Returns a static file from a remote source. /// </summary> - public static class FileStreamResponseHelpers + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param> + /// <param name="httpContext">The current http context.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> + /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns> + public static async Task<ActionResult> GetStaticRemoteStreamResult( + StreamState state, + HttpClient httpClient, + HttpContext httpContext, + CancellationToken cancellationToken = default) { - /// <summary> - /// Returns a static file from a remote source. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param> - /// <param name="httpContext">The current http context.</param> - /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> - /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns> - public static async Task<ActionResult> GetStaticRemoteStreamResult( - StreamState state, - HttpClient httpClient, - HttpContext httpContext, - CancellationToken cancellationToken = default) + if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { - if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) - { - httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent); - } + httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent); + } - // Can't dispose the response as it's required up the call chain. - var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false); - var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain; + // Can't dispose the response as it's required up the call chain. + var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain; - httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; + httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; - return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); - } + return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); + } - /// <summary> - /// Returns a static file from the server. - /// </summary> - /// <param name="path">The path to the file.</param> - /// <param name="contentType">The content type of the file.</param> - /// <returns>An <see cref="ActionResult"/> the file.</returns> - public static ActionResult GetStaticFileResult( - string path, - string contentType) - { - return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true }; - } + /// <summary> + /// Returns a static file from the server. + /// </summary> + /// <param name="path">The path to the file.</param> + /// <param name="contentType">The content type of the file.</param> + /// <returns>An <see cref="ActionResult"/> the file.</returns> + public static ActionResult GetStaticFileResult( + string path, + string contentType) + { + return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true }; + } - /// <summary> - /// Returns a transcoded file from the server. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> - /// <param name="httpContext">The current http context.</param> - /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> - /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param> - /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> - /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param> - /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns> - public static async Task<ActionResult> GetTranscodedFile( - StreamState state, - bool isHeadRequest, - HttpContext httpContext, - TranscodingJobHelper transcodingJobHelper, - string ffmpegCommandLineArguments, - TranscodingJobType transcodingJobType, - CancellationTokenSource cancellationTokenSource) - { - // Use the command line args with a dummy playlist path - var outputPath = state.OutputFilePath; + /// <summary> + /// Returns a transcoded file from the server. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> + /// <param name="httpContext">The current http context.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> + /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param> + /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns> + public static async Task<ActionResult> GetTranscodedFile( + StreamState state, + bool isHeadRequest, + HttpContext httpContext, + TranscodingJobHelper transcodingJobHelper, + string ffmpegCommandLineArguments, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource) + { + // Use the command line args with a dummy playlist path + var outputPath = state.OutputFilePath; - httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; + httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; - var contentType = state.GetMimeType(outputPath); + var contentType = state.GetMimeType(outputPath); - // Headers only - if (isHeadRequest) - { - httpContext.Response.Headers[HeaderNames.ContentType] = contentType; - return new OkResult(); - } + // Headers only + if (isHeadRequest) + { + httpContext.Response.Headers[HeaderNames.ContentType] = contentType; + return new OkResult(); + } - var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try + var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + try + { + TranscodingJobDto? job; + if (!File.Exists(outputPath)) { - TranscodingJobDto? job; - if (!File.Exists(outputPath)) - { - job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); - } - else - { - job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); - state.Dispose(); - } - - var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); - return new FileStreamResult(stream, contentType); + job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); } - finally + else { - transcodingLock.Release(); + job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); + state.Dispose(); } + + var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); + return new FileStreamResult(stream, contentType); + } + finally + { + transcodingLock.Release(); } } } diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index cbe82979bc..9a141a16d9 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -2,182 +2,239 @@ using System.Globalization; using System.Text; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Hls Codec string helpers. +/// </summary> +public static class HlsCodecStringHelpers { /// <summary> - /// Hls Codec string helpers. + /// Codec name for MP3. + /// </summary> + public const string MP3 = "mp4a.40.34"; + + /// <summary> + /// Codec name for AC-3. + /// </summary> + public const string AC3 = "mp4a.a5"; + + /// <summary> + /// Codec name for E-AC-3. + /// </summary> + public const string EAC3 = "mp4a.a6"; + + /// <summary> + /// Codec name for FLAC. + /// </summary> + public const string FLAC = "flac"; + + /// <summary> + /// Codec name for ALAC. + /// </summary> + public const string ALAC = "alac"; + + /// <summary> + /// Codec name for OPUS. /// </summary> - public static class HlsCodecStringHelpers + public const string OPUS = "opus"; + + /// <summary> + /// Gets a MP3 codec string. + /// </summary> + /// <returns>MP3 codec string.</returns> + public static string GetMP3String() { - /// <summary> - /// Codec name for MP3. - /// </summary> - public const string MP3 = "mp4a.40.34"; - - /// <summary> - /// Codec name for AC-3. - /// </summary> - public const string AC3 = "mp4a.a5"; - - /// <summary> - /// Codec name for E-AC-3. - /// </summary> - public const string EAC3 = "mp4a.a6"; - - /// <summary> - /// Codec name for FLAC. - /// </summary> - public const string FLAC = "flac"; - - /// <summary> - /// Codec name for ALAC. - /// </summary> - public const string ALAC = "alac"; - - /// <summary> - /// Codec name for OPUS. - /// </summary> - public const string OPUS = "opus"; - - /// <summary> - /// Gets a MP3 codec string. - /// </summary> - /// <returns>MP3 codec string.</returns> - public static string GetMP3String() + return MP3; + } + + /// <summary> + /// Gets an AAC codec string. + /// </summary> + /// <param name="profile">AAC profile.</param> + /// <returns>AAC codec string.</returns> + public static string GetAACString(string? profile) + { + StringBuilder result = new StringBuilder("mp4a", 9); + + if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase)) { - return MP3; + result.Append(".40.5"); } - - /// <summary> - /// Gets an AAC codec string. - /// </summary> - /// <param name="profile">AAC profile.</param> - /// <returns>AAC codec string.</returns> - public static string GetAACString(string? profile) + else { - StringBuilder result = new StringBuilder("mp4a", 9); - - if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".40.5"); - } - else - { - // Default to LC if profile is invalid - result.Append(".40.2"); - } - - return result.ToString(); + // Default to LC if profile is invalid + result.Append(".40.2"); } - /// <summary> - /// Gets an AC-3 codec string. - /// </summary> - /// <returns>AC-3 codec string.</returns> - public static string GetAC3String() + return result.ToString(); + } + + /// <summary> + /// Gets an AC-3 codec string. + /// </summary> + /// <returns>AC-3 codec string.</returns> + public static string GetAC3String() + { + return AC3; + } + + /// <summary> + /// Gets an E-AC-3 codec string. + /// </summary> + /// <returns>E-AC-3 codec string.</returns> + public static string GetEAC3String() + { + return EAC3; + } + + /// <summary> + /// Gets an FLAC codec string. + /// </summary> + /// <returns>FLAC codec string.</returns> + public static string GetFLACString() + { + return FLAC; + } + + /// <summary> + /// Gets an ALAC codec string. + /// </summary> + /// <returns>ALAC codec string.</returns> + public static string GetALACString() + { + return ALAC; + } + + /// <summary> + /// Gets an OPUS codec string. + /// </summary> + /// <returns>OPUS codec string.</returns> + public static string GetOPUSString() + { + return OPUS; + } + + /// <summary> + /// Gets a H.264 codec string. + /// </summary> + /// <param name="profile">H.264 profile.</param> + /// <param name="level">H.264 level.</param> + /// <returns>H.264 string.</returns> + public static string GetH264String(string? profile, int level) + { + StringBuilder result = new StringBuilder("avc1", 11); + + if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase)) { - return AC3; + result.Append(".6400"); } - - /// <summary> - /// Gets an E-AC-3 codec string. - /// </summary> - /// <returns>E-AC-3 codec string.</returns> - public static string GetEAC3String() + else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase)) { - return EAC3; + result.Append(".4D40"); } - - /// <summary> - /// Gets an FLAC codec string. - /// </summary> - /// <returns>FLAC codec string.</returns> - public static string GetFLACString() + else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase)) { - return FLAC; + result.Append(".42E0"); } + else + { + // Default to constrained baseline if profile is invalid + result.Append(".4240"); + } + + string levelHex = level.ToString("X2", CultureInfo.InvariantCulture); + result.Append(levelHex); - /// <summary> - /// Gets an ALAC codec string. - /// </summary> - /// <returns>ALAC codec string.</returns> - public static string GetALACString() + return result.ToString(); + } + + /// <summary> + /// Gets a H.265 codec string. + /// </summary> + /// <param name="profile">H.265 profile.</param> + /// <param name="level">H.265 level.</param> + /// <returns>H.265 string.</returns> + public static string GetH265String(string? profile, int level) + { + // The h265 syntax is a bit of a mystery at the time this comment was written. + // This is what I've found through various sources: + // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] + StringBuilder result = new StringBuilder("hvc1", 16); + + if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) { - return ALAC; + result.Append(".2.4"); } + else + { + // Default to main if profile is invalid + result.Append(".1.4"); + } + + result.Append(".L") + .Append(level) + .Append(".B0"); - /// <summary> - /// Gets an OPUS codec string. - /// </summary> - /// <returns>OPUS codec string.</returns> - public static string GetOPUSString() + return result.ToString(); + } + + /// <summary> + /// Gets an AV1 codec string. + /// </summary> + /// <param name="profile">AV1 profile.</param> + /// <param name="level">AV1 level.</param> + /// <param name="tierFlag">AV1 tier flag.</param> + /// <param name="bitDepth">AV1 bit depth.</param> + /// <returns>The AV1 codec string.</returns> + public static string GetAv1String(string? profile, int level, bool tierFlag, int bitDepth) + { + // https://aomedia.org/av1/specification/annex-a/ + // FORMAT: [codecTag].[profile].[level][tier].[bitDepth] + StringBuilder result = new StringBuilder("av01", 13); + + if (string.Equals(profile, "Main", StringComparison.OrdinalIgnoreCase)) + { + result.Append(".0"); + } + else if (string.Equals(profile, "High", StringComparison.OrdinalIgnoreCase)) + { + result.Append(".1"); + } + else if (string.Equals(profile, "Professional", StringComparison.OrdinalIgnoreCase)) { - return OPUS; + result.Append(".2"); + } + else + { + // Default to Main + result.Append(".0"); } - /// <summary> - /// Gets a H.264 codec string. - /// </summary> - /// <param name="profile">H.264 profile.</param> - /// <param name="level">H.264 level.</param> - /// <returns>H.264 string.</returns> - public static string GetH264String(string? profile, int level) + if (level <= 0 + || level > 31) { - StringBuilder result = new StringBuilder("avc1", 11); - - if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".6400"); - } - else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".4D40"); - } - else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".42E0"); - } - else - { - // Default to constrained baseline if profile is invalid - result.Append(".4240"); - } - - string levelHex = level.ToString("X2", CultureInfo.InvariantCulture); - result.Append(levelHex); - - return result.ToString(); + // Default to the maximum defined level 6.3 + level = 19; } - /// <summary> - /// Gets a H.265 codec string. - /// </summary> - /// <param name="profile">H.265 profile.</param> - /// <param name="level">H.265 level.</param> - /// <returns>H.265 string.</returns> - public static string GetH265String(string? profile, int level) + if (bitDepth != 8 + && bitDepth != 10 + && bitDepth != 12) { - // The h265 syntax is a bit of a mystery at the time this comment was written. - // This is what I've found through various sources: - // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] - StringBuilder result = new StringBuilder("hvc1", 16); - - if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) - || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".2.4"); - } - else - { - // Default to main if profile is invalid - result.Append(".1.4"); - } - - result.Append(".L") - .Append(level) - .Append(".B0"); - - return result.ToString(); + // Default to 8 bits + bitDepth = 8; } + + result.Append('.') + .Append(level) + .Append(tierFlag ? 'H' : 'M'); + + string bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture); + result.Append('.') + .Append(bitDepthD2); + + return result.ToString(); } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index 671107c1ff..2155e305da 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -8,131 +8,130 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// The hls helpers. +/// </summary> +public static class HlsHelpers { /// <summary> - /// The hls helpers. + /// Waits for a minimum number of segments to be available. /// </summary> - public static class HlsHelpers + /// <param name="playlist">The playlist string.</param> + /// <param name="segmentCount">The segment count.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A <see cref="Task"/> indicating the waiting process.</returns> + public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken) { - /// <summary> - /// Waits for a minimum number of segments to be available. - /// </summary> - /// <param name="playlist">The playlist string.</param> - /// <param name="segmentCount">The segment count.</param> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> - /// <returns>A <see cref="Task"/> indicating the waiting process.</returns> - public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken) - { - logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist); + logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist); - while (!cancellationToken.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) + { + try { - try + // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written + var fileStream = new FileStream( + playlist, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using (fileStream.ConfigureAwait(false)) { - // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written - var fileStream = new FileStream( - playlist, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite, - IODefaults.FileStreamBufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - await using (fileStream.ConfigureAwait(false)) - { - using var reader = new StreamReader(fileStream); - var count = 0; + using var reader = new StreamReader(fileStream); + var count = 0; - while (!reader.EndOfStream) + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) { - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line is null) - { - // Nothing currently in buffer. - break; - } + // Nothing currently in buffer. + break; + } - if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) + if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) + { + count++; + if (count >= segmentCount) { - count++; - if (count >= segmentCount) - { - logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist); - return; - } + logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist); + return; } } } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - catch (IOException) - { - // May get an error if the file is locked } - await Task.Delay(50, cancellationToken).ConfigureAwait(false); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); } - } - - /// <summary> - /// Gets the #EXT-X-MAP string. - /// </summary> - /// <param name="outputPath">The output path of the file.</param> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <param name="isOsDepends">Get a normal string or depends on OS.</param> - /// <returns>The string text of #EXT-X-MAP.</returns> - public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) - { - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); - var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); - var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - - // on Linux/Unix - // #EXT-X-MAP:URI="prefix-1.mp4" - var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; - if (!isOsDepends) + catch (IOException) { - return fmp4InitFileName; + // May get an error if the file is locked } - if (OperatingSystem.IsWindows()) - { - // on Windows - // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" - fmp4InitFileName = outputPrefix + "-1" + outputExtension; - } + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + /// <summary> + /// Gets the #EXT-X-MAP string. + /// </summary> + /// <param name="outputPath">The output path of the file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="isOsDepends">Get a normal string or depends on OS.</param> + /// <returns>The string text of #EXT-X-MAP.</returns> + public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + + // on Linux/Unix + // #EXT-X-MAP:URI="prefix-1.mp4" + var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; + if (!isOsDepends) + { return fmp4InitFileName; } - /// <summary> - /// Gets the hls playlist text. - /// </summary> - /// <param name="path">The path to the playlist file.</param> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <returns>The playlist text as a string.</returns> - public static string GetLivePlaylistText(string path, StreamState state) + if (OperatingSystem.IsWindows()) { - var text = File.ReadAllText(path); + // on Windows + // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" + fmp4InitFileName = outputPrefix + "-1" + outputExtension; + } - var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); - if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) - { - var fmp4InitFileName = GetFmp4InitFileName(path, state, true); - var baseUrlParam = string.Format( - CultureInfo.InvariantCulture, - "hls/{0}/", - Path.GetFileNameWithoutExtension(path)); - var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); + return fmp4InitFileName; + } - // Replace fMP4 init file URI. - text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); - } + /// <summary> + /// Gets the hls playlist text. + /// </summary> + /// <param name="path">The path to the playlist file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The playlist text as a string.</returns> + public static string GetLivePlaylistText(string path, StreamState state) + { + var text = File.ReadAllText(path); + + var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var fmp4InitFileName = GetFmp4InitFileName(path, state, true); + var baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + "hls/{0}/", + Path.GetFileNameWithoutExtension(path)); + var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); - return text; + // Replace fMP4 init file URI. + text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); } + + return text; } } diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index e0245fe4da..5910d80737 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -25,476 +25,475 @@ using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Media info helper. +/// </summary> +public class MediaInfoHelper { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILogger<MediaInfoHelper> _logger; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + /// <summary> - /// Media info helper. + /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class. /// </summary> - public class MediaInfoHelper + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + public MediaInfoHelper( + IUserManager userManager, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IMediaEncoder mediaEncoder, + IServerConfigurationManager serverConfigurationManager, + ILogger<MediaInfoHelper> logger, + INetworkManager networkManager, + IDeviceManager deviceManager) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly ILogger<MediaInfoHelper> _logger; - private readonly INetworkManager _networkManager; - private readonly IDeviceManager _deviceManager; - - /// <summary> - /// Initializes a new instance of the <see cref="MediaInfoHelper"/> 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="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - public MediaInfoHelper( - IUserManager userManager, - ILibraryManager libraryManager, - IMediaSourceManager mediaSourceManager, - IMediaEncoder mediaEncoder, - IServerConfigurationManager serverConfigurationManager, - ILogger<MediaInfoHelper> logger, - INetworkManager networkManager, - IDeviceManager deviceManager) - { - _userManager = userManager; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - _mediaEncoder = mediaEncoder; - _serverConfigurationManager = serverConfigurationManager; - _logger = logger; - _networkManager = networkManager; - _deviceManager = deviceManager; - } + _userManager = userManager; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _mediaEncoder = mediaEncoder; + _serverConfigurationManager = serverConfigurationManager; + _logger = logger; + _networkManager = networkManager; + _deviceManager = deviceManager; + } - /// <summary> - /// Get playback info. - /// </summary> - /// <param name="id">Item id.</param> - /// <param name="userId">User Id.</param> - /// <param name="mediaSourceId">Media source id.</param> - /// <param name="liveStreamId">Live stream id.</param> - /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> - public async Task<PlaybackInfoResponse> GetPlaybackInfo( - Guid id, - Guid? userId, - string? mediaSourceId = null, - string? liveStreamId = null) + /// <summary> + /// Get playback info. + /// </summary> + /// <param name="id">Item id.</param> + /// <param name="userId">User Id.</param> + /// <param name="mediaSourceId">Media source id.</param> + /// <param name="liveStreamId">Live stream id.</param> + /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> + public async Task<PlaybackInfoResponse> GetPlaybackInfo( + Guid id, + Guid? userId, + string? mediaSourceId = null, + string? liveStreamId = null) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById(id); + var result = new PlaybackInfoResponse(); + + MediaSourceInfo[] mediaSources; + if (string.IsNullOrWhiteSpace(liveStreamId)) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var item = _libraryManager.GetItemById(id); - var result = new PlaybackInfoResponse(); - - MediaSourceInfo[] mediaSources; - if (string.IsNullOrWhiteSpace(liveStreamId)) - { - // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? - var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(mediaSourceId)) - { - mediaSources = mediaSourcesList.ToArray(); - } - else - { - mediaSources = mediaSourcesList - .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - else - { - var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - mediaSources = new[] { mediaSource }; - } - - if (mediaSources.Length == 0) + if (string.IsNullOrWhiteSpace(mediaSourceId)) { - result.MediaSources = Array.Empty<MediaSourceInfo>(); - - result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; + mediaSources = mediaSourcesList.ToArray(); } else { - // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it - // Should we move this directly into MediaSourceManager? - var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); - if (mediaSourcesClone is not null) - { - result.MediaSources = mediaSourcesClone; - } - - result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + mediaSources = mediaSourcesList + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); } + } + else + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - return result; + mediaSources = new[] { mediaSource }; } - /// <summary> - /// SetDeviceSpecificData. - /// </summary> - /// <param name="item">Item to set data for.</param> - /// <param name="mediaSource">Media source info.</param> - /// <param name="profile">Device profile.</param> - /// <param name="claimsPrincipal">Current claims principal.</param> - /// <param name="maxBitrate">Max bitrate.</param> - /// <param name="startTimeTicks">Start time ticks.</param> - /// <param name="mediaSourceId">Media source id.</param> - /// <param name="audioStreamIndex">Audio stream index.</param> - /// <param name="subtitleStreamIndex">Subtitle stream index.</param> - /// <param name="maxAudioChannels">Max audio channels.</param> - /// <param name="playSessionId">Play session id.</param> - /// <param name="userId">User id.</param> - /// <param name="enableDirectPlay">Enable direct play.</param> - /// <param name="enableDirectStream">Enable direct stream.</param> - /// <param name="enableTranscoding">Enable transcoding.</param> - /// <param name="allowVideoStreamCopy">Allow video stream copy.</param> - /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param> - /// <param name="ipAddress">Requesting IP address.</param> - public void SetDeviceSpecificData( - BaseItem item, - MediaSourceInfo mediaSource, - DeviceProfile profile, - ClaimsPrincipal claimsPrincipal, - int? maxBitrate, - long startTimeTicks, - string mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - int? maxAudioChannels, - string playSessionId, - Guid userId, - bool enableDirectPlay, - bool enableDirectStream, - bool enableTranscoding, - bool allowVideoStreamCopy, - bool allowAudioStreamCopy, - IPAddress ipAddress) + if (mediaSources.Length == 0) { - var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); + result.MediaSources = Array.Empty<MediaSourceInfo>(); - var options = new MediaOptions - { - MediaSources = new[] { mediaSource }, - Context = EncodingContext.Streaming, - DeviceId = claimsPrincipal.GetDeviceId(), - ItemId = item.Id, - Profile = profile, - MaxAudioChannels = maxAudioChannels, - AllowAudioStreamCopy = allowAudioStreamCopy, - AllowVideoStreamCopy = allowVideoStreamCopy - }; - - if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) + result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; + } + else + { + // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it + // Should we move this directly into MediaSourceManager? + var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); + if (mediaSourcesClone is not null) { - options.MediaSourceId = mediaSourceId; - options.AudioStreamIndex = audioStreamIndex; - options.SubtitleStreamIndex = subtitleStreamIndex; + result.MediaSources = mediaSourcesClone; } - var user = _userManager.GetUserById(userId); + result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } - if (!enableDirectPlay) - { - mediaSource.SupportsDirectPlay = false; - } + return result; + } - if (!enableDirectStream || !allowVideoStreamCopy) - { - mediaSource.SupportsDirectStream = false; - } + /// <summary> + /// SetDeviceSpecificData. + /// </summary> + /// <param name="item">Item to set data for.</param> + /// <param name="mediaSource">Media source info.</param> + /// <param name="profile">Device profile.</param> + /// <param name="claimsPrincipal">Current claims principal.</param> + /// <param name="maxBitrate">Max bitrate.</param> + /// <param name="startTimeTicks">Start time ticks.</param> + /// <param name="mediaSourceId">Media source id.</param> + /// <param name="audioStreamIndex">Audio stream index.</param> + /// <param name="subtitleStreamIndex">Subtitle stream index.</param> + /// <param name="maxAudioChannels">Max audio channels.</param> + /// <param name="playSessionId">Play session id.</param> + /// <param name="userId">User id.</param> + /// <param name="enableDirectPlay">Enable direct play.</param> + /// <param name="enableDirectStream">Enable direct stream.</param> + /// <param name="enableTranscoding">Enable transcoding.</param> + /// <param name="allowVideoStreamCopy">Allow video stream copy.</param> + /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param> + /// <param name="ipAddress">Requesting IP address.</param> + public void SetDeviceSpecificData( + BaseItem item, + MediaSourceInfo mediaSource, + DeviceProfile profile, + ClaimsPrincipal claimsPrincipal, + int? maxBitrate, + long startTimeTicks, + string mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + int? maxAudioChannels, + string playSessionId, + Guid userId, + bool enableDirectPlay, + bool enableDirectStream, + bool enableTranscoding, + bool allowVideoStreamCopy, + bool allowAudioStreamCopy, + IPAddress ipAddress) + { + var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); - if (!enableTranscoding) - { - mediaSource.SupportsTranscoding = false; - } + var options = new MediaOptions + { + MediaSources = new[] { mediaSource }, + Context = EncodingContext.Streaming, + DeviceId = claimsPrincipal.GetDeviceId(), + ItemId = item.Id, + Profile = profile, + MaxAudioChannels = maxAudioChannels, + AllowAudioStreamCopy = allowAudioStreamCopy, + AllowVideoStreamCopy = allowVideoStreamCopy + }; + + if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) + { + options.MediaSourceId = mediaSourceId; + options.AudioStreamIndex = audioStreamIndex; + options.SubtitleStreamIndex = subtitleStreamIndex; + } - if (item is Audio) - { - _logger.LogInformation( - "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", - user.Username, - user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); - } - else - { - _logger.LogInformation( - "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", - user.Username, - user.HasPermission(PermissionKind.EnablePlaybackRemuxing), - user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), - user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); - } + var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); - options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); + if (!enableDirectPlay) + { + mediaSource.SupportsDirectPlay = false; + } - if (!options.ForceDirectStream) - { - // direct-stream http streaming is currently broken - options.EnableDirectStream = false; - } + if (!enableDirectStream || !allowVideoStreamCopy) + { + mediaSource.SupportsDirectStream = false; + } - // Beginning of Playback Determination - var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) - ? streamBuilder.GetOptimalAudioStream(options) - : streamBuilder.GetOptimalVideoStream(options); + if (!enableTranscoding) + { + mediaSource.SupportsTranscoding = false; + } - if (streamInfo is not null) - { - streamInfo.PlaySessionId = playSessionId; - streamInfo.StartPositionTicks = startTimeTicks; + if (item is Audio) + { + _logger.LogInformation( + "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", + user.Username, + user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); + } + else + { + _logger.LogInformation( + "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", + user.Username, + user.HasPermission(PermissionKind.EnablePlaybackRemuxing), + user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), + user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); + } - mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay; + options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); - // Players do not handle this being set according to PlayMethod - mediaSource.SupportsDirectStream = - options.EnableDirectStream - ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream - : streamInfo.PlayMethod == PlayMethod.DirectPlay; + if (!options.ForceDirectStream) + { + // direct-stream http streaming is currently broken + options.EnableDirectStream = false; + } - mediaSource.SupportsTranscoding = - streamInfo.PlayMethod == PlayMethod.DirectStream - || mediaSource.TranscodingContainer is not null - || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context); + // Beginning of Playback Determination + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) + ? streamBuilder.GetOptimalAudioStream(options) + : streamBuilder.GetOptimalVideoStream(options); - if (item is Audio) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) - { - mediaSource.SupportsTranscoding = false; - } - } - else if (item is Video) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) - { - mediaSource.SupportsTranscoding = false; - } - } + if (streamInfo is not null) + { + streamInfo.PlaySessionId = playSessionId; + streamInfo.StartPositionTicks = startTimeTicks; - if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) - { - mediaSource.SupportsDirectPlay = false; - mediaSource.SupportsDirectStream = false; + mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; - mediaSource.TranscodingContainer = streamInfo.Container; - mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; - } - else + // Players do not handle this being set according to PlayMethod + mediaSource.SupportsDirectStream = + options.EnableDirectStream + ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream + : streamInfo.PlayMethod == PlayMethod.DirectPlay; + + mediaSource.SupportsTranscoding = + streamInfo.PlayMethod == PlayMethod.DirectStream + || mediaSource.TranscodingContainer is not null + || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context); + + if (item is Audio) + { + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) { - if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream)) - { - streamInfo.PlayMethod = PlayMethod.Transcode; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - - if (!allowVideoStreamCopy) - { - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - } - - if (!allowAudioStreamCopy) - { - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; - } - } + mediaSource.SupportsTranscoding = false; } - - // Do this after the above so that StartPositionTicks is set - // The token must not be null - SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!); - mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } - - foreach (var attachment in mediaSource.MediaAttachments) + else if (item is Video) { - attachment.DeliveryUrl = string.Format( - CultureInfo.InvariantCulture, - "/Videos/{0}/{1}/Attachments/{2}", - item.Id, - mediaSource.Id, - attachment.Index); + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) + { + mediaSource.SupportsTranscoding = false; + } } - } - /// <summary> - /// Sort media source. - /// </summary> - /// <param name="result">Playback info response.</param> - /// <param name="maxBitrate">Max bitrate.</param> - public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) - { - var originalList = result.MediaSources.ToList(); + if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) + { + mediaSource.SupportsDirectPlay = false; + mediaSource.SupportsDirectStream = false; - result.MediaSources = result.MediaSources.OrderBy(i => + mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); + mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + mediaSource.TranscodingContainer = streamInfo.Container; + mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; + } + else + { + if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream)) { - // Nothing beats direct playing a file - if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) - { - return 0; - } + streamInfo.PlayMethod = PlayMethod.Transcode; + mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - return 1; - }) - .ThenBy(i => - { - // Let's assume direct streaming a file is just as desirable as direct playing a remote url - if (i.SupportsDirectPlay || i.SupportsDirectStream) + if (!allowVideoStreamCopy) { - return 0; + mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; } - return 1; - }) - .ThenBy(i => - { - return i.Protocol switch + if (!allowAudioStreamCopy) { - MediaProtocol.File => 0, - _ => 1, - }; - }) - .ThenBy(i => - { - if (maxBitrate.HasValue && i.Bitrate.HasValue) - { - return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; } + } + } - return 1; - }) - .ThenBy(originalList.IndexOf) - .ToArray(); + // Do this after the above so that StartPositionTicks is set + // The token must not be null + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!); + mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } - /// <summary> - /// Open media source. - /// </summary> - /// <param name="httpContext">Http Context.</param> - /// <param name="request">Live stream request.</param> - /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns> - public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request) + foreach (var attachment in mediaSource.MediaAttachments) { - var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + attachment.DeliveryUrl = string.Format( + CultureInfo.InvariantCulture, + "/Videos/{0}/{1}/Attachments/{2}", + item.Id, + mediaSource.Id, + attachment.Index); + } + } - var profile = request.DeviceProfile; - if (profile is null) + /// <summary> + /// Sort media source. + /// </summary> + /// <param name="result">Playback info response.</param> + /// <param name="maxBitrate">Max bitrate.</param> + public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) + { + var originalList = result.MediaSources.ToList(); + + result.MediaSources = result.MediaSources.OrderBy(i => { - var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId()); - if (clientCapabilities is not null) + // Nothing beats direct playing a file + if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) { - profile = clientCapabilities.DeviceProfile; + return 0; } - } - if (profile is not null) + return 1; + }) + .ThenBy(i => { - var item = _libraryManager.GetItemById(request.ItemId); - - SetDeviceSpecificData( - item, - result.MediaSource, - profile, - httpContext.User, - request.MaxStreamingBitrate, - request.StartTimeTicks ?? 0, - result.MediaSource.Id, - request.AudioStreamIndex, - request.SubtitleStreamIndex, - request.MaxAudioChannels, - request.PlaySessionId, - request.UserId, - request.EnableDirectPlay, - request.EnableDirectStream, - true, - true, - true, - httpContext.GetNormalizedRemoteIp()); - } - else + // Let's assume direct streaming a file is just as desirable as direct playing a remote url + if (i.SupportsDirectPlay || i.SupportsDirectStream) + { + return 0; + } + + return 1; + }) + .ThenBy(i => + { + return i.Protocol switch + { + MediaProtocol.File => 0, + _ => 1, + }; + }) + .ThenBy(i => { - if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) + if (maxBitrate.HasValue && i.Bitrate.HasValue) { - result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; + return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; } - } - // here was a check if (result.MediaSource is not null) but Rider said it will never be null - NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); + return 1; + }) + .ThenBy(originalList.IndexOf) + .ToArray(); + } - return result; - } + /// <summary> + /// Open media source. + /// </summary> + /// <param name="httpContext">Http Context.</param> + /// <param name="request">Live stream request.</param> + /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns> + public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request) + { + var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); - /// <summary> - /// Normalize media source container. - /// </summary> - /// <param name="mediaSource">Media source.</param> - /// <param name="profile">Device profile.</param> - /// <param name="type">Dlna profile type.</param> - public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) + var profile = request.DeviceProfile; + if (profile is null) { - mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type); + var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId()); + if (clientCapabilities is not null) + { + profile = clientCapabilities.DeviceProfile; + } } - private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) + if (profile is not null) + { + var item = _libraryManager.GetItemById(request.ItemId); + + SetDeviceSpecificData( + item, + result.MediaSource, + profile, + httpContext.User, + request.MaxStreamingBitrate, + request.StartTimeTicks ?? 0, + result.MediaSource.Id, + request.AudioStreamIndex, + request.SubtitleStreamIndex, + request.MaxAudioChannels, + request.PlaySessionId, + request.UserId, + request.EnableDirectPlay, + request.EnableDirectStream, + true, + true, + true, + httpContext.GetNormalizedRemoteIp()); + } + else { - var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); - mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; + if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) + { + result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; + } + } + + // here was a check if (result.MediaSource is not null) but Rider said it will never be null + NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); - mediaSource.TranscodeReasons = info.TranscodeReasons; + return result; + } - foreach (var profile in profiles) + /// <summary> + /// Normalize media source container. + /// </summary> + /// <param name="mediaSource">Media source.</param> + /// <param name="profile">Device profile.</param> + /// <param name="type">Dlna profile type.</param> + public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) + { + mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type); + } + + private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) + { + var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); + mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; + + mediaSource.TranscodeReasons = info.TranscodeReasons; + + foreach (var profile in profiles) + { + foreach (var stream in mediaSource.MediaStreams) { - foreach (var stream in mediaSource.MediaStreams) + if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) { - if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) - { - stream.DeliveryMethod = profile.DeliveryMethod; + stream.DeliveryMethod = profile.DeliveryMethod; - if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) - { - stream.DeliveryUrl = profile.Url.TrimStart('-'); - stream.IsExternalUrl = profile.IsExternalUrl; - } + if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) + { + stream.DeliveryUrl = profile.Url.TrimStart('-'); + stream.IsExternalUrl = profile.IsExternalUrl; } } } } + } + + private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress) + { + var maxBitrate = clientMaxBitrate; + var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; - private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress) + if (remoteClientMaxBitrate <= 0) { - var maxBitrate = clientMaxBitrate; - var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; + remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; + } - if (remoteClientMaxBitrate <= 0) - { - remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; - } + if (remoteClientMaxBitrate > 0) + { + var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); - if (remoteClientMaxBitrate > 0) + _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); + if (!isInLocalNetwork) { - var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); - - _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); - if (!isInLocalNetwork) - { - maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); - } + maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); } - - return maxBitrate; } + + return maxBitrate; } } diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs index dfeeea2b0e..d7b1c9f8bb 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -6,178 +6,177 @@ using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; using MediaBrowser.Model.IO; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// A progressive file stream for transferring transcoded files as they are written to. +/// </summary> +public class ProgressiveFileStream : Stream { + private readonly Stream _stream; + private readonly TranscodingJobDto? _job; + private readonly TranscodingJobHelper? _transcodingJobHelper; + private readonly int _timeoutMs; + private bool _disposed; + /// <summary> - /// A progressive file stream for transferring transcoded files as they are written to. + /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. /// </summary> - public class ProgressiveFileStream : Stream + /// <param name="filePath">The path to the transcoded file.</param> + /// <param name="job">The transcoding job information.</param> + /// <param name="transcodingJobHelper">The transcoding job helper.</param> + /// <param name="timeoutMs">The timeout duration in milliseconds.</param> + public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) { - private readonly Stream _stream; - private readonly TranscodingJobDto? _job; - private readonly TranscodingJobHelper? _transcodingJobHelper; - private readonly int _timeoutMs; - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. - /// </summary> - /// <param name="filePath">The path to the transcoded file.</param> - /// <param name="job">The transcoding job information.</param> - /// <param name="transcodingJobHelper">The transcoding job helper.</param> - /// <param name="timeoutMs">The timeout duration in milliseconds.</param> - public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) - { - _job = job; - _transcodingJobHelper = transcodingJobHelper; - _timeoutMs = timeoutMs; + _job = job; + _transcodingJobHelper = transcodingJobHelper; + _timeoutMs = timeoutMs; - _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); - } + _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + } - /// <summary> - /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. - /// </summary> - /// <param name="stream">The stream to progressively copy.</param> - /// <param name="timeoutMs">The timeout duration in milliseconds.</param> - public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) - { - _job = null; - _transcodingJobHelper = null; - _timeoutMs = timeoutMs; - _stream = stream; - } + /// <summary> + /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. + /// </summary> + /// <param name="stream">The stream to progressively copy.</param> + /// <param name="timeoutMs">The timeout duration in milliseconds.</param> + public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) + { + _job = null; + _transcodingJobHelper = null; + _timeoutMs = timeoutMs; + _stream = stream; + } - /// <inheritdoc /> - public override bool CanRead => _stream.CanRead; + /// <inheritdoc /> + public override bool CanRead => _stream.CanRead; - /// <inheritdoc /> - public override bool CanSeek => false; + /// <inheritdoc /> + public override bool CanSeek => false; - /// <inheritdoc /> - public override bool CanWrite => false; + /// <inheritdoc /> + public override bool CanWrite => false; - /// <inheritdoc /> - public override long Length => throw new NotSupportedException(); + /// <inheritdoc /> + public override long Length => throw new NotSupportedException(); - /// <inheritdoc /> - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } + /// <inheritdoc /> + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } - /// <inheritdoc /> - public override void Flush() - { - // Not supported - } + /// <inheritdoc /> + public override void Flush() + { + // Not supported + } - /// <inheritdoc /> - public override int Read(byte[] buffer, int offset, int count) - => Read(buffer.AsSpan(offset, count)); + /// <inheritdoc /> + public override int Read(byte[] buffer, int offset, int count) + => Read(buffer.AsSpan(offset, count)); - /// <inheritdoc /> - public override int Read(Span<byte> buffer) - { - int totalBytesRead = 0; - var stopwatch = Stopwatch.StartNew(); + /// <inheritdoc /> + public override int Read(Span<byte> buffer) + { + int totalBytesRead = 0; + var stopwatch = Stopwatch.StartNew(); - while (true) + while (true) + { + totalBytesRead += _stream.Read(buffer); + if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) { - totalBytesRead += _stream.Read(buffer); - if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) - { - break; - } - - Thread.Sleep(50); + break; } - UpdateBytesWritten(totalBytesRead); - - return totalBytesRead; + Thread.Sleep(50); } - /// <inheritdoc /> - public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + UpdateBytesWritten(totalBytesRead); - /// <inheritdoc /> - public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) - { - int totalBytesRead = 0; - var stopwatch = Stopwatch.StartNew(); + return totalBytesRead; + } - while (true) - { - totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) - { - break; - } + /// <inheritdoc /> + public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } + /// <inheritdoc /> + public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) + { + int totalBytesRead = 0; + var stopwatch = Stopwatch.StartNew(); - UpdateBytesWritten(totalBytesRead); + while (true) + { + totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) + { + break; + } - return totalBytesRead; + await Task.Delay(50, cancellationToken).ConfigureAwait(false); } - /// <inheritdoc /> - public override long Seek(long offset, SeekOrigin origin) - => throw new NotSupportedException(); + UpdateBytesWritten(totalBytesRead); + + return totalBytesRead; + } + + /// <inheritdoc /> + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); - /// <inheritdoc /> - public override void SetLength(long value) - => throw new NotSupportedException(); + /// <inheritdoc /> + public override void SetLength(long value) + => throw new NotSupportedException(); - /// <inheritdoc /> - public override void Write(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); + /// <inheritdoc /> + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); - /// <inheritdoc /> - protected override void Dispose(bool disposing) + /// <inheritdoc /> + protected override void Dispose(bool disposing) + { + if (_disposed) { - if (_disposed) - { - return; - } + return; + } - try + try + { + if (disposing) { - if (disposing) - { - _stream.Dispose(); + _stream.Dispose(); - if (_job is not null) - { - _transcodingJobHelper?.OnTranscodeEndRequest(_job); - } + if (_job is not null) + { + _transcodingJobHelper?.OnTranscodeEndRequest(_job); } } - finally - { - _disposed = true; - base.Dispose(disposing); - } } - - private void UpdateBytesWritten(int totalBytesRead) + finally { - if (_job is not null) - { - _job.BytesDownloaded += totalBytesRead; - } + _disposed = true; + base.Dispose(disposing); } + } - private bool StopReading(int bytesRead, long elapsed) + private void UpdateBytesWritten(int totalBytesRead) + { + if (_job is not null) { - // It should stop reading when anything has been successfully read or if the job has exited - // If the job is null, however, it's a live stream and will require user action to close, - // but don't keep it open indefinitely if it isn't reading anything - return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs); + _job.BytesDownloaded += totalBytesRead; } } + + private bool StopReading(int bytesRead, long elapsed) + { + // It should stop reading when anything has been successfully read or if the job has exited + // If the job is null, however, it's a live stream and will require user action to close, + // but don't keep it open indefinitely if it isn't reading anything + return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs); + } } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 035d84513a..57098edbae 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -11,138 +11,169 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Request Extensions. +/// </summary> +public static class RequestHelpers { /// <summary> - /// Request Extensions. + /// Get Order By. /// </summary> - public static class RequestHelpers + /// <param name="sortBy">Sort By. Comma delimited string.</param> + /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> + /// <returns>Order By.</returns> + public static (string, SortOrder)[] GetOrderBy(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) { - /// <summary> - /// Get Order By. - /// </summary> - /// <param name="sortBy">Sort By. Comma delimited string.</param> - /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> - /// <returns>Order By.</returns> - public static (string, SortOrder)[] GetOrderBy(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) + if (sortBy.Count == 0) { - if (sortBy.Count == 0) - { - return Array.Empty<(string, SortOrder)>(); - } + return Array.Empty<(string, SortOrder)>(); + } - var result = new (string, SortOrder)[sortBy.Count]; - var i = 0; - // Add elements which have a SortOrder specified - for (; i < requestedSortOrder.Count; i++) - { - result[i] = (sortBy[i], requestedSortOrder[i]); - } + var result = new (string, SortOrder)[sortBy.Count]; + var i = 0; + // Add elements which have a SortOrder specified + for (; i < requestedSortOrder.Count; i++) + { + result[i] = (sortBy[i], requestedSortOrder[i]); + } - // Add remaining elements with the first specified SortOrder - // or the default one if no SortOrders are specified - var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; - for (; i < sortBy.Count; i++) - { - result[i] = (sortBy[i], order); - } + // Add remaining elements with the first specified SortOrder + // or the default one if no SortOrders are specified + var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; + for (; i < sortBy.Count; i++) + { + result[i] = (sortBy[i], order); + } - return result; + return result; + } + + /// <summary> + /// Checks if the user can access a user. + /// </summary> + /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param> + /// <param name="userId">The user id.</param> + /// <returns>A <see cref="bool"/> whether the user can access the user.</returns> + internal static Guid GetUserId(ClaimsPrincipal claimsPrincipal, Guid? userId) + { + var authenticatedUserId = claimsPrincipal.GetUserId(); + + // UserId not provided, fall back to authenticated user id. + if (userId is null || userId.Value.Equals(default)) + { + return authenticatedUserId; } - /// <summary> - /// Checks if the user can update an entry. - /// </summary> - /// <param name="userManager">An instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param> - /// <param name="userId">The user id.</param> - /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> - /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> - internal static bool AssertCanUpdateUser(IUserManager userManager, ClaimsPrincipal claimsPrincipal, Guid userId, bool restrictUserPreferences) + // User must be administrator to access another user. + var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator); + if (!userId.Value.Equals(authenticatedUserId) && !isAdministrator) { - var authenticatedUserId = claimsPrincipal.GetUserId(); - var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator); + throw new SecurityException("Forbidden"); + } - // If they're going to update the record of another user, they must be an administrator - if (!userId.Equals(authenticatedUserId) && !isAdministrator) - { - return false; - } + return userId.Value; + } - // TODO the EnableUserPreferenceAccess policy does not seem to be used elsewhere - if (!restrictUserPreferences || isAdministrator) - { - return true; - } + /// <summary> + /// Checks if the user can update an entry. + /// </summary> + /// <param name="userManager">An instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param> + /// <param name="userId">The user id.</param> + /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> + /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> + internal static bool AssertCanUpdateUser(IUserManager userManager, ClaimsPrincipal claimsPrincipal, Guid userId, bool restrictUserPreferences) + { + var authenticatedUserId = claimsPrincipal.GetUserId(); + var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator); - var user = userManager.GetUserById(userId); - return user.EnableUserPreferenceAccess; + // If they're going to update the record of another user, they must be an administrator + if (!userId.Equals(authenticatedUserId) && !isAdministrator) + { + return false; } - internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + // TODO the EnableUserPreferenceAccess policy does not seem to be used elsewhere + if (!restrictUserPreferences || isAdministrator) { - var userId = httpContext.User.GetUserId(); - var user = userManager.GetUserById(userId); - var session = await sessionManager.LogSessionActivity( - httpContext.User.GetClient(), - httpContext.User.GetVersion(), - httpContext.User.GetDeviceId(), - httpContext.User.GetDevice(), - httpContext.GetNormalizedRemoteIp().ToString(), - user).ConfigureAwait(false); - - if (session is null) - { - throw new ArgumentException("Session not found."); - } - - return session; + return true; } - internal static async Task<string> GetSessionId(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + var user = userManager.GetUserById(userId); + if (user is null) { - var session = await GetSession(sessionManager, userManager, httpContext).ConfigureAwait(false); + throw new ResourceNotFoundException(); + } + + return user.EnableUserPreferenceAccess; + } - return session.Id; + internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + { + var userId = httpContext.User.GetUserId(); + var user = userManager.GetUserById(userId); + var session = await sessionManager.LogSessionActivity( + httpContext.User.GetClient(), + httpContext.User.GetVersion(), + httpContext.User.GetDeviceId(), + httpContext.User.GetDevice(), + httpContext.GetNormalizedRemoteIp().ToString(), + user).ConfigureAwait(false); + + if (session is null) + { + throw new ResourceNotFoundException("Session not found."); } - internal static QueryResult<BaseItemDto> CreateQueryResult( - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, - DtoOptions dtoOptions, - IDtoService dtoService, - bool includeItemTypes, - User? user) + return session; + } + + internal static async Task<string> GetSessionId(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + { + var session = await GetSession(sessionManager, userManager, httpContext).ConfigureAwait(false); + + return session.Id; + } + + internal static QueryResult<BaseItemDto> CreateQueryResult( + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, + DtoOptions dtoOptions, + IDtoService dtoService, + bool includeItemTypes, + User? user) + { + var dtos = result.Items.Select(i => { - var dtos = result.Items.Select(i => + var (baseItem, counts) = i; + var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + + if (includeItemTypes) { - var (baseItem, counts) = i; - var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - result.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); - } + dto.ChildCount = counts.ItemCount; + dto.ProgramCount = counts.ProgramCount; + dto.SeriesCount = counts.SeriesCount; + dto.EpisodeCount = counts.EpisodeCount; + dto.MovieCount = counts.MovieCount; + dto.TrailerCount = counts.TrailerCount; + dto.AlbumCount = counts.AlbumCount; + dto.SongCount = counts.SongCount; + dto.ArtistCount = counts.ArtistCount; + } + + return dto; + }); + + return new QueryResult<BaseItemDto>( + result.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); } } diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index d4fc9c020a..782cd65685 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -22,761 +22,771 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// The streaming helpers. +/// </summary> +public static class StreamingHelpers { /// <summary> - /// The streaming helpers. + /// Gets the current streaming state. /// </summary> - public static class StreamingHelpers + /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param> + /// <param name="httpContext">The <see cref="HttpContext"/>.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns> + public static async Task<StreamState> GetStreamingState( + StreamingRequestDto streamingRequest, + HttpContext httpContext, + IMediaSourceManager mediaSourceManager, + IUserManager userManager, + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + EncodingHelper encodingHelper, + IDlnaManager dlnaManager, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + TranscodingJobType transcodingJobType, + CancellationToken cancellationToken) { - /// <summary> - /// Gets the current streaming state. - /// </summary> - /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param> - /// <param name="httpContext">The <see cref="HttpContext"/>.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param> - /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> - /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> - /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns> - public static async Task<StreamState> GetStreamingState( - StreamingRequestDto streamingRequest, - HttpContext httpContext, - IMediaSourceManager mediaSourceManager, - IUserManager userManager, - ILibraryManager libraryManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - EncodingHelper encodingHelper, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - TranscodingJobType transcodingJobType, - CancellationToken cancellationToken) - { - var httpRequest = httpContext.Request; - // Parse the DLNA time seek header - if (!streamingRequest.StartTimeTicks.HasValue) - { - var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"]; + var httpRequest = httpContext.Request; + // Parse the DLNA time seek header + if (!streamingRequest.StartTimeTicks.HasValue) + { + var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"]; - streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString()); - } + streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString()); + } - if (!string.IsNullOrWhiteSpace(streamingRequest.Params)) - { - ParseParams(streamingRequest); - } + if (!string.IsNullOrWhiteSpace(streamingRequest.Params)) + { + ParseParams(streamingRequest); + } - streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); - if (httpRequest.Path.Value is null) - { - throw new ResourceNotFoundException(nameof(httpRequest.Path)); - } + streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); + if (httpRequest.Path.Value is null) + { + throw new ResourceNotFoundException(nameof(httpRequest.Path)); + } - var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString(); + var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString(); - if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) - { - streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url); - } + if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) + { + streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url); + } - var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) || - streamingRequest.StreamOptions.ContainsKey("dlnaheaders") || - string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); + var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) || + streamingRequest.StreamOptions.ContainsKey("dlnaheaders") || + string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); - var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) - { - Request = streamingRequest, - RequestedUrl = url, - UserAgent = httpRequest.Headers[HeaderNames.UserAgent], - EnableDlnaHeaders = enableDlnaHeaders - }; - - var userId = httpContext.User.GetUserId(); - if (!userId.Equals(default)) - { - state.User = userManager.GetUserById(userId); - } + var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) + { + Request = streamingRequest, + RequestedUrl = url, + UserAgent = httpRequest.Headers[HeaderNames.UserAgent], + EnableDlnaHeaders = enableDlnaHeaders + }; + + var userId = httpContext.User.GetUserId(); + if (!userId.Equals(default)) + { + state.User = userManager.GetUserById(userId); + } - if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec)) - { - state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); - } + if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec)) + { + state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); + } - if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec)) - { - state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec) - ?? state.SupportedAudioCodecs.FirstOrDefault(); - } + if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec)) + { + state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec) + ?? state.SupportedAudioCodecs.FirstOrDefault(); + } - if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec)) - { - state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec) - ?? state.SupportedSubtitleCodecs.FirstOrDefault(); - } + if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec)) + { + state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec) + ?? state.SupportedSubtitleCodecs.FirstOrDefault(); + } - var item = libraryManager.GetItemById(streamingRequest.Id); + var item = libraryManager.GetItemById(streamingRequest.Id); - state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); - MediaSourceInfo? mediaSource = null; - if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)) - { - var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId) - ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId) - : null; + MediaSourceInfo? mediaSource = null; + if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)) + { + var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId) + ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId) + : null; - if (currentJob is not null) - { - mediaSource = currentJob.MediaSource; - } + if (currentJob is not null) + { + mediaSource = currentJob.MediaSource; + } - if (mediaSource is null) - { - var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); + if (mediaSource is null) + { + var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); - mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) - ? mediaSources[0] - : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal)); + mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) + ? mediaSources[0] + : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal)); - if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id)) - { - mediaSource = mediaSources[0]; - } + if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id)) + { + mediaSource = mediaSources[0]; } } - else - { - var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); - mediaSource = liveStreamInfo.Item1; - state.DirectStreamProvider = liveStreamInfo.Item2; - } + } + else + { + var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); + mediaSource = liveStreamInfo.Item1; + state.DirectStreamProvider = liveStreamInfo.Item2; + } - var encodingOptions = serverConfigurationManager.GetEncodingOptions(); + var encodingOptions = serverConfigurationManager.GetEncodingOptions(); - encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); + encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); - string? containerInternal = Path.GetExtension(state.RequestedUrl); + string? containerInternal = Path.GetExtension(state.RequestedUrl); - if (!string.IsNullOrEmpty(streamingRequest.Container)) - { - containerInternal = streamingRequest.Container; - } + if (!string.IsNullOrEmpty(streamingRequest.Container)) + { + containerInternal = streamingRequest.Container; + } - if (string.IsNullOrEmpty(containerInternal)) - { - containerInternal = streamingRequest.Static ? - StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio) - : GetOutputFileExtension(state, mediaSource); - } + if (string.IsNullOrEmpty(containerInternal)) + { + containerInternal = streamingRequest.Static ? + StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio) + : GetOutputFileExtension(state, mediaSource); + } - state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); + var outputAudioCodec = streamingRequest.AudioCodec; + if (EncodingHelper.LosslessAudioCodecs.Contains(outputAudioCodec)) + { + state.OutputAudioBitrate = state.AudioStream.BitRate ?? 0; + } + else + { + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0; + } - state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); + state.OutputAudioCodec = outputAudioCodec; + state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); + state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); - state.OutputAudioCodec = streamingRequest.AudioCodec; + if (state.VideoRequest is not null) + { + state.OutputVideoCodec = state.Request.VideoCodec; + state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); + encodingHelper.TryStreamCopy(state); - if (state.VideoRequest is not null) + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { - state.OutputVideoCodec = state.Request.VideoCodec; - state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - - encodingHelper.TryStreamCopy(state); + var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue + && !state.VideoRequest.Height.HasValue + && !state.VideoRequest.MaxWidth.HasValue + && !state.VideoRequest.MaxHeight.HasValue; - if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) + if (isVideoResolutionNotRequested + && state.VideoStream is not null + && state.VideoRequest.VideoBitRate.HasValue + && state.VideoStream.BitRate.HasValue + && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) { - var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue - && !state.VideoRequest.Height.HasValue - && !state.VideoRequest.MaxWidth.HasValue - && !state.VideoRequest.MaxHeight.HasValue; - - if (isVideoResolutionNotRequested - && state.VideoStream is not null - && state.VideoRequest.VideoBitRate.HasValue - && state.VideoStream.BitRate.HasValue - && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) - { - // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, - // and the requested video bitrate is higher than source video bitrate. - if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) - { - state.VideoRequest.MaxWidth = state.VideoStream?.Width; - state.VideoRequest.MaxHeight = state.VideoStream?.Height; - } - } - else + // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, + // and the requested video bitrate is higher than source video bitrate. + if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) { - var resolution = ResolutionNormalizer.Normalize( - state.VideoStream?.BitRate, - state.OutputVideoBitrate.Value, - state.VideoRequest.MaxWidth, - state.VideoRequest.MaxHeight); - - state.VideoRequest.MaxWidth = resolution.MaxWidth; - state.VideoRequest.MaxHeight = resolution.MaxHeight; + state.VideoRequest.MaxWidth = state.VideoStream?.Width; + state.VideoRequest.MaxHeight = state.VideoStream?.Height; } } + else + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream?.BitRate, + state.OutputVideoBitrate.Value, + state.VideoRequest.MaxWidth, + state.VideoRequest.MaxHeight); + + state.VideoRequest.MaxWidth = resolution.MaxWidth; + state.VideoRequest.MaxHeight = resolution.MaxHeight; + } } + } - ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static); + ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static); - var ext = string.IsNullOrWhiteSpace(state.OutputContainer) - ? GetOutputFileExtension(state, mediaSource) - : ("." + state.OutputContainer); + var ext = string.IsNullOrWhiteSpace(state.OutputContainer) + ? GetOutputFileExtension(state, mediaSource) + : ("." + state.OutputContainer); - state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); + state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); - return state; - } + return state; + } - /// <summary> - /// Adds the dlna headers. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="responseHeaders">The response headers.</param> - /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - /// <param name="request">The <see cref="HttpRequest"/>.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - public static void AddDlnaHeaders( - StreamState state, - IHeaderDictionary responseHeaders, - bool isStaticallyStreamed, - long? startTimeTicks, - HttpRequest request, - IDlnaManager dlnaManager) + /// <summary> + /// Adds the dlna headers. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="responseHeaders">The response headers.</param> + /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="request">The <see cref="HttpRequest"/>.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public static void AddDlnaHeaders( + StreamState state, + IHeaderDictionary responseHeaders, + bool isStaticallyStreamed, + long? startTimeTicks, + HttpRequest request, + IDlnaManager dlnaManager) + { + if (!state.EnableDlnaHeaders) { - if (!state.EnableDlnaHeaders) - { - return; - } + return; + } - var profile = state.DeviceProfile; + var profile = state.DeviceProfile; - StringValues transferMode = request.Headers["transferMode.dlna.org"]; - responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString()); - responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"); + StringValues transferMode = request.Headers["transferMode.dlna.org"]; + responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString()); + responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"); - if (state.RunTimeTicks.HasValue) + if (state.RunTimeTicks.HasValue) + { + if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase)) - { - var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; - responseHeaders.Add("MediaInfo.sec", string.Format( - CultureInfo.InvariantCulture, - "SEC_Duration={0};", - Convert.ToInt32(ms))); - } + var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; + responseHeaders.Add("MediaInfo.sec", string.Format( + CultureInfo.InvariantCulture, + "SEC_Duration={0};", + Convert.ToInt32(ms))); + } - if (!isStaticallyStreamed && profile is not null) - { - AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks); - } + if (!isStaticallyStreamed && profile is not null) + { + AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks); } + } - profile ??= dlnaManager.GetDefaultProfile(); + profile ??= dlnaManager.GetDefaultProfile(); - var audioCodec = state.ActualOutputAudioCodec; + var audioCodec = state.ActualOutputAudioCodec; - if (!state.IsVideoRequest) - { - responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader( - profile, - state.OutputContainer, - audioCodec, - state.OutputAudioBitrate, - state.OutputAudioSampleRate, - state.OutputAudioChannels, - state.OutputAudioBitDepth, - isStaticallyStreamed, - state.RunTimeTicks, - state.TranscodeSeekInfo)); - } - else - { - var videoCodec = state.ActualOutputVideoCodec; + if (!state.IsVideoRequest) + { + responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader( + profile, + state.OutputContainer, + audioCodec, + state.OutputAudioBitrate, + state.OutputAudioSampleRate, + state.OutputAudioChannels, + state.OutputAudioBitDepth, + isStaticallyStreamed, + state.RunTimeTicks, + state.TranscodeSeekInfo)); + } + else + { + var videoCodec = state.ActualOutputVideoCodec; - responseHeaders.Add( - "contentFeatures.dlna.org", - ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoRangeType, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); - } + responseHeaders.Add( + "contentFeatures.dlna.org", + ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoRangeType, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); } + } - /// <summary> - /// Parses the time seek header. - /// </summary> - /// <param name="value">The time seek header string.</param> - /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns> - private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value) + /// <summary> + /// Parses the time seek header. + /// </summary> + /// <param name="value">The time seek header string.</param> + /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns> + private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value) + { + if (value.IsEmpty) { - if (value.IsEmpty) - { - return null; - } + return null; + } - const string npt = "npt="; - if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("Invalid timeseek header"); - } + const string npt = "npt="; + if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid timeseek header"); + } - var index = value.IndexOf('-'); - value = index == -1 - ? value.Slice(npt.Length) - : value.Slice(npt.Length, index - npt.Length); - if (value.IndexOf(':') == -1) + var index = value.IndexOf('-'); + value = index == -1 + ? value.Slice(npt.Length) + : value.Slice(npt.Length, index - npt.Length); + if (!value.Contains(':')) + { + // Parses npt times in the format of '417.33' + if (double.TryParse(value, CultureInfo.InvariantCulture, out var seconds)) { - // Parses npt times in the format of '417.33' - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) - { - return TimeSpan.FromSeconds(seconds).Ticks; - } - - throw new ArgumentException("Invalid timeseek header"); + return TimeSpan.FromSeconds(seconds).Ticks; } - try - { - // Parses npt times in the format of '10:19:25.7' - return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks; - } - catch - { - throw new ArgumentException("Invalid timeseek header"); - } + throw new ArgumentException("Invalid timeseek header"); } - /// <summary> - /// Parses query parameters as StreamOptions. - /// </summary> - /// <param name="queryString">The query string.</param> - /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns> - private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString) + try + { + // Parses npt times in the format of '10:19:25.7' + return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks; + } + catch { - Dictionary<string, string?> streamOptions = new Dictionary<string, string?>(); - foreach (var param in queryString) + throw new ArgumentException("Invalid timeseek header"); + } + } + + /// <summary> + /// Parses query parameters as StreamOptions. + /// </summary> + /// <param name="queryString">The query string.</param> + /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns> + private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString) + { + Dictionary<string, string?> streamOptions = new Dictionary<string, string?>(); + foreach (var param in queryString) + { + if (char.IsLower(param.Key[0])) { - if (char.IsLower(param.Key[0])) - { - // This was probably not parsed initially and should be a StreamOptions - // or the generated URL should correctly serialize it - // TODO: This should be incorporated either in the lower framework for parsing requests - streamOptions[param.Key] = param.Value; - } + // This was probably not parsed initially and should be a StreamOptions + // or the generated URL should correctly serialize it + // TODO: This should be incorporated either in the lower framework for parsing requests + streamOptions[param.Key] = param.Value; } - - return streamOptions; } - /// <summary> - /// Adds the dlna time seek headers to the response. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks) - { - var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); - var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); + return streamOptions; + } - responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( - CultureInfo.InvariantCulture, - "npt={0}-{1}/{1}", - startSeconds, - runtimeSeconds)); - responseHeaders.Add("X-AvailableSeekRange", string.Format( - CultureInfo.InvariantCulture, - "1 npt={0}-{1}", - startSeconds, - runtimeSeconds)); + /// <summary> + /// Adds the dlna time seek headers to the response. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks) + { + var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); + var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); + + responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( + CultureInfo.InvariantCulture, + "npt={0}-{1}/{1}", + startSeconds, + runtimeSeconds)); + responseHeaders.Add("X-AvailableSeekRange", string.Format( + CultureInfo.InvariantCulture, + "1 npt={0}-{1}", + startSeconds, + runtimeSeconds)); + } + + /// <summary> + /// Gets the output file extension. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="mediaSource">The mediaSource.</param> + /// <returns>System.String.</returns> + private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource) + { + var ext = Path.GetExtension(state.RequestedUrl); + + if (!string.IsNullOrEmpty(ext)) + { + return ext; } - /// <summary> - /// Gets the output file extension. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="mediaSource">The mediaSource.</param> - /// <returns>System.String.</returns> - private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource) + // Try to infer based on the desired video codec + if (state.IsVideoRequest) { - var ext = Path.GetExtension(state.RequestedUrl); + var videoCodec = state.Request.VideoCodec; - if (!string.IsNullOrEmpty(ext)) + if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)) { - return ext; + return ".ts"; } - // Try to infer based on the desired video codec - if (state.IsVideoRequest) + if (string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase)) { - var videoCodec = state.Request.VideoCodec; - - if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) || - string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - return ".ts"; - } - - if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) - { - return ".ogv"; - } - - if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) - { - return ".webm"; - } + return ".mp4"; + } - if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) - { - return ".asf"; - } + if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) + { + return ".ogv"; } - // Try to infer based on the desired audio codec - if (!state.IsVideoRequest) + if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) { - var audioCodec = state.Request.AudioCodec; + return ".webm"; + } - if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".aac"; - } + if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) + { + return ".asf"; + } + } - if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".mp3"; - } + // Try to infer based on the desired audio codec + if (!state.IsVideoRequest) + { + var audioCodec = state.Request.AudioCodec; - if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".ogg"; - } + if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".aac"; + } - if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".wma"; - } + if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".mp3"; } - // Fallback to the container of mediaSource - if (!string.IsNullOrEmpty(mediaSource?.Container)) + if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) { - var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase); - return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim(); + return ".ogg"; } - return null; + if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".wma"; + } } - /// <summary> - /// Gets the output file path for transcoding. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="outputFileExtension">The file extension of the output file.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="deviceId">The device id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <returns>The complete file path, including the folder, for the transcoding file.</returns> - private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId) + // Fallback to the container of mediaSource + if (!string.IsNullOrEmpty(mediaSource?.Container)) { - var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}"; + var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase); + return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim(); + } - var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); - var ext = outputFileExtension?.ToLowerInvariant(); - var folder = serverConfigurationManager.GetTranscodePath(); + return null; + } - return Path.Combine(folder, filename + ext); - } + /// <summary> + /// Gets the output file path for transcoding. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="outputFileExtension">The file extension of the output file.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <returns>The complete file path, including the folder, for the transcoding file.</returns> + private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId) + { + var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}"; - private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) - { - if (!string.IsNullOrWhiteSpace(deviceProfileId)) - { - state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); + var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var ext = outputFileExtension?.ToLowerInvariant(); + var folder = serverConfigurationManager.GetTranscodePath(); - if (state.DeviceProfile is null) - { - var caps = deviceManager.GetCapabilities(deviceProfileId); - state.DeviceProfile = caps is null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile; - } - } + return Path.Combine(folder, filename + ext); + } - var profile = state.DeviceProfile; + private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) + { + if (!string.IsNullOrWhiteSpace(deviceProfileId)) + { + state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); - if (profile is null) + if (state.DeviceProfile is null) { - // Don't use settings from the default profile. - // Only use a specific profile if it was requested. - return; + var caps = deviceManager.GetCapabilities(deviceProfileId); + state.DeviceProfile = caps is null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile; } + } - var audioCodec = state.ActualOutputAudioCodec; - var videoCodec = state.ActualOutputVideoCodec; + var profile = state.DeviceProfile; - var mediaProfile = !state.IsVideoRequest - ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) - : profile.GetVideoMediaProfile( - state.OutputContainer, - audioCodec, - videoCodec, - state.OutputWidth, - state.OutputHeight, - state.TargetVideoBitDepth, - state.OutputVideoBitrate, - state.TargetVideoProfile, - state.TargetVideoRangeType, - state.TargetVideoLevel, - state.TargetFramerate, - state.TargetPacketLength, - state.TargetTimestamp, - state.IsTargetAnamorphic, - state.IsTargetInterlaced, - state.TargetRefFrames, - state.TargetVideoStreamCount, - state.TargetAudioStreamCount, - state.TargetVideoCodecTag, - state.IsTargetAVC); - - if (mediaProfile is not null) - { - state.MimeType = mediaProfile.MimeType; - } + if (profile is null) + { + // Don't use settings from the default profile. + // Only use a specific profile if it was requested. + return; + } + + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + + var mediaProfile = !state.IsVideoRequest + ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) + : profile.GetVideoMediaProfile( + state.OutputContainer, + audioCodec, + videoCodec, + state.OutputWidth, + state.OutputHeight, + state.TargetVideoBitDepth, + state.OutputVideoBitrate, + state.TargetVideoProfile, + state.TargetVideoRangeType, + state.TargetVideoLevel, + state.TargetFramerate, + state.TargetPacketLength, + state.TargetTimestamp, + state.IsTargetAnamorphic, + state.IsTargetInterlaced, + state.TargetRefFrames, + state.TargetVideoStreamCount, + state.TargetAudioStreamCount, + state.TargetVideoCodecTag, + state.IsTargetAVC); + + if (mediaProfile is not null) + { + state.MimeType = mediaProfile.MimeType; + } - if (!(@static.HasValue && @static.Value)) + if (!(@static.HasValue && @static.Value)) + { + var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); + + if (transcodingProfile is not null) { - var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); + state.EstimateContentLength = transcodingProfile.EstimateContentLength; + // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; + state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; - if (transcodingProfile is not null) + if (state.VideoRequest is not null) { - state.EstimateContentLength = transcodingProfile.EstimateContentLength; - // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; - state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; - - if (state.VideoRequest is not null) - { - state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; - state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; - } + state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; + state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; } } } + } - /// <summary> - /// Parses the parameters. - /// </summary> - /// <param name="request">The request.</param> - private static void ParseParams(StreamingRequestDto request) + /// <summary> + /// Parses the parameters. + /// </summary> + /// <param name="request">The request.</param> + private static void ParseParams(StreamingRequestDto request) + { + if (string.IsNullOrEmpty(request.Params)) { - if (string.IsNullOrEmpty(request.Params)) - { - return; - } + return; + } - var vals = request.Params.Split(';'); + var vals = request.Params.Split(';'); - var videoRequest = request as VideoRequestDto; + var videoRequest = request as VideoRequestDto; - for (var i = 0; i < vals.Length; i++) - { - var val = vals[i]; + for (var i = 0; i < vals.Length; i++) + { + var val = vals[i]; - if (string.IsNullOrWhiteSpace(val)) - { - continue; - } + if (string.IsNullOrWhiteSpace(val)) + { + continue; + } - switch (i) - { - case 0: - request.DeviceProfileId = val; - break; - case 1: - request.DeviceId = val; - break; - case 2: - request.MediaSourceId = val; - break; - case 3: - request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - break; - case 4: - if (videoRequest is not null) - { - videoRequest.VideoCodec = val; - } + switch (i) + { + case 0: + request.DeviceProfileId = val; + break; + case 1: + request.DeviceId = val; + break; + case 2: + request.MediaSourceId = val; + break; + case 3: + request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + break; + case 4: + if (videoRequest is not null) + { + videoRequest.VideoCodec = val; + } - break; - case 5: - request.AudioCodec = val; - break; - case 6: - if (videoRequest is not null) - { - videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 5: + request.AudioCodec = val; + break; + case 6: + if (videoRequest is not null) + { + videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 7: - if (videoRequest is not null) - { - videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 7: + if (videoRequest is not null) + { + videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 8: - if (videoRequest is not null) - { - videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 8: + if (videoRequest is not null) + { + videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 9: - request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 10: - request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 11: - if (videoRequest is not null) - { - videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 9: + request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 10: + request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 11: + if (videoRequest is not null) + { + videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 12: - if (videoRequest is not null) - { - videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 12: + if (videoRequest is not null) + { + videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 13: - if (videoRequest is not null) - { - videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 13: + if (videoRequest is not null) + { + videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 14: - request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); - break; - case 15: - if (videoRequest is not null) - { - videoRequest.Level = val; - } + break; + case 14: + request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); + break; + case 15: + if (videoRequest is not null) + { + videoRequest.Level = val; + } - break; - case 16: - if (videoRequest is not null) - { - videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 16: + if (videoRequest is not null) + { + videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 17: - if (videoRequest is not null) - { - videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 17: + if (videoRequest is not null) + { + videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 18: - if (videoRequest is not null) - { - videoRequest.Profile = val; - } + break; + case 18: + if (videoRequest is not null) + { + videoRequest.Profile = val; + } - break; - case 19: - // cabac no longer used - break; - case 20: - request.PlaySessionId = val; - break; - case 21: - // api_key - break; - case 22: - request.LiveStreamId = val; - break; - case 23: - // Duplicating ItemId because of MediaMonkey - break; - case 24: - if (videoRequest is not null) - { - videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 19: + // cabac no longer used + break; + case 20: + request.PlaySessionId = val; + break; + case 21: + // api_key + break; + case 22: + request.LiveStreamId = val; + break; + case 23: + // Duplicating ItemId because of MediaMonkey + break; + case 24: + if (videoRequest is not null) + { + videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 25: - if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null) + break; + case 25: + if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null) + { + if (Enum.TryParse(val, out SubtitleDeliveryMethod method)) { - if (Enum.TryParse(val, out SubtitleDeliveryMethod method)) - { - videoRequest.SubtitleMethod = method; - } + videoRequest.SubtitleMethod = method; } + } - break; - case 26: - request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 27: - if (videoRequest is not null) - { - videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 26: + request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 27: + if (videoRequest is not null) + { + videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 28: - request.Tag = val; - break; - case 29: - if (videoRequest is not null) - { - videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 28: + request.Tag = val; + break; + case 29: + if (videoRequest is not null) + { + videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 30: - request.SubtitleCodec = val; - break; - case 31: - if (videoRequest is not null) - { - videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 30: + request.SubtitleCodec = val; + break; + case 31: + if (videoRequest is not null) + { + videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 32: - if (videoRequest is not null) - { - videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 32: + if (videoRequest is not null) + { + videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 33: - request.TranscodeReasons = val; - break; - } + break; + case 33: + request.TranscodeReasons = val; + break; } } } diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 77dd518608..cee8e0f9be 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -27,888 +27,898 @@ using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Transcoding job helpers. +/// </summary> +public class TranscodingJobHelper : IDisposable { /// <summary> - /// Transcoding job helpers. + /// The active transcoding jobs. + /// </summary> + private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>(); + + /// <summary> + /// The transcoding locks. /// </summary> - public class TranscodingJobHelper : IDisposable + private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>(); + + private readonly IAttachmentExtractor _attachmentExtractor; + private readonly IApplicationPaths _appPaths; + private readonly EncodingHelper _encodingHelper; + private readonly IFileSystem _fileSystem; + private readonly ILogger<TranscodingJobHelper> _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISessionManager _sessionManager; + private readonly ILoggerFactory _loggerFactory; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class. + /// </summary> + /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public TranscodingJobHelper( + IAttachmentExtractor attachmentExtractor, + IApplicationPaths appPaths, + ILogger<TranscodingJobHelper> logger, + IMediaSourceManager mediaSourceManager, + IFileSystem fileSystem, + IMediaEncoder mediaEncoder, + IServerConfigurationManager serverConfigurationManager, + ISessionManager sessionManager, + EncodingHelper encodingHelper, + ILoggerFactory loggerFactory, + IUserManager userManager) { - /// <summary> - /// The active transcoding jobs. - /// </summary> - private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>(); - - /// <summary> - /// The transcoding locks. - /// </summary> - private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>(); - - private readonly IAttachmentExtractor _attachmentExtractor; - private readonly IApplicationPaths _appPaths; - private readonly EncodingHelper _encodingHelper; - private readonly IFileSystem _fileSystem; - private readonly ILogger<TranscodingJobHelper> _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly ISessionManager _sessionManager; - private readonly ILoggerFactory _loggerFactory; - private readonly IUserManager _userManager; - - /// <summary> - /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class. - /// </summary> - /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> - /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public TranscodingJobHelper( - IAttachmentExtractor attachmentExtractor, - IApplicationPaths appPaths, - ILogger<TranscodingJobHelper> logger, - IMediaSourceManager mediaSourceManager, - IFileSystem fileSystem, - IMediaEncoder mediaEncoder, - IServerConfigurationManager serverConfigurationManager, - ISessionManager sessionManager, - EncodingHelper encodingHelper, - ILoggerFactory loggerFactory, - IUserManager userManager) - { - _attachmentExtractor = attachmentExtractor; - _appPaths = appPaths; - _logger = logger; - _mediaSourceManager = mediaSourceManager; - _fileSystem = fileSystem; - _mediaEncoder = mediaEncoder; - _serverConfigurationManager = serverConfigurationManager; - _sessionManager = sessionManager; - _encodingHelper = encodingHelper; - _loggerFactory = loggerFactory; - _userManager = userManager; - - DeleteEncodedMediaCache(); - - sessionManager.PlaybackProgress += OnPlaybackProgress; - sessionManager.PlaybackStart += OnPlaybackProgress; - } - - /// <summary> - /// Get transcoding job. - /// </summary> - /// <param name="playSessionId">Playback session id.</param> - /// <returns>The transcoding job.</returns> - public TranscodingJobDto? GetTranscodingJob(string playSessionId) - { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); - } - } + _attachmentExtractor = attachmentExtractor; + _appPaths = appPaths; + _logger = logger; + _mediaSourceManager = mediaSourceManager; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _serverConfigurationManager = serverConfigurationManager; + _sessionManager = sessionManager; + _encodingHelper = encodingHelper; + _loggerFactory = loggerFactory; + _userManager = userManager; + + DeleteEncodedMediaCache(); + + sessionManager.PlaybackProgress += OnPlaybackProgress; + sessionManager.PlaybackStart += OnPlaybackProgress; + } - /// <summary> - /// Get transcoding job. - /// </summary> - /// <param name="path">Path to the transcoding file.</param> - /// <param name="type">The <see cref="TranscodingJobType"/>.</param> - /// <returns>The transcoding job.</returns> - public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) + /// <summary> + /// Get transcoding job. + /// </summary> + /// <param name="playSessionId">Playback session id.</param> + /// <returns>The transcoding job.</returns> + public TranscodingJobDto? GetTranscodingJob(string playSessionId) + { + lock (_activeTranscodingJobs) { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - } + return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); } + } - /// <summary> - /// Ping transcoding job. - /// </summary> - /// <param name="playSessionId">Play session id.</param> - /// <param name="isUserPaused">Is user paused.</param> - /// <exception cref="ArgumentNullException">Play session id is null.</exception> - public void PingTranscodingJob(string playSessionId, bool? isUserPaused) + /// <summary> + /// Get transcoding job. + /// </summary> + /// <param name="path">Path to the transcoding file.</param> + /// <param name="type">The <see cref="TranscodingJobType"/>.</param> + /// <returns>The transcoding job.</returns> + public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) { - ArgumentException.ThrowIfNullOrEmpty(playSessionId); + return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + } + } - _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); + /// <summary> + /// Ping transcoding job. + /// </summary> + /// <param name="playSessionId">Play session id.</param> + /// <param name="isUserPaused">Is user paused.</param> + /// <exception cref="ArgumentNullException">Play session id is null.</exception> + public void PingTranscodingJob(string playSessionId, bool? isUserPaused) + { + ArgumentException.ThrowIfNullOrEmpty(playSessionId); - List<TranscodingJobDto> jobs; + _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); - lock (_activeTranscodingJobs) + List<TranscodingJobDto> jobs; + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably. + jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + foreach (var job in jobs) + { + if (isUserPaused.HasValue) { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably. - jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); + _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); + job.IsUserPaused = isUserPaused.Value; } - foreach (var job in jobs) - { - if (isUserPaused.HasValue) - { - _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); - job.IsUserPaused = isUserPaused.Value; - } + PingTimer(job, true); + } + } - PingTimer(job, true); - } + private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn) + { + if (job.HasExited) + { + job.StopKillTimer(); + return; } - private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn) + var timerDuration = 10000; + + if (job.Type != TranscodingJobType.Progressive) { - if (job.HasExited) - { - job.StopKillTimer(); - return; - } + timerDuration = 60000; + } - var timerDuration = 10000; + job.PingTimeout = timerDuration; + job.LastPingDate = DateTime.UtcNow; - if (job.Type != TranscodingJobType.Progressive) - { - timerDuration = 60000; - } + // Don't start the timer for playback checkins with progressive streaming + if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) + { + job.StartKillTimer(OnTranscodeKillTimerStopped); + } + else + { + job.ChangeKillTimerIfStarted(); + } + } - job.PingTimeout = timerDuration; - job.LastPingDate = DateTime.UtcNow; + /// <summary> + /// Called when [transcode kill timer stopped]. + /// </summary> + /// <param name="state">The state.</param> + private async void OnTranscodeKillTimerStopped(object? state) + { + var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); + if (!job.HasExited && job.Type != TranscodingJobType.Progressive) + { + var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; - // Don't start the timer for playback checkins with progressive streaming - if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) + if (timeSinceLastPing < job.PingTimeout) { - job.StartKillTimer(OnTranscodeKillTimerStopped); - } - else - { - job.ChangeKillTimerIfStarted(); + job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); + return; } } - /// <summary> - /// Called when [transcode kill timer stopped]. - /// </summary> - /// <param name="state">The state.</param> - private async void OnTranscodeKillTimerStopped(object? state) - { - var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); - if (!job.HasExited && job.Type != TranscodingJobType.Progressive) - { - var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; + _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - if (timeSinceLastPing < job.PingTimeout) - { - job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); - return; - } - } + await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); + } - _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + /// <summary> + /// Kills the single transcoding job. + /// </summary> + /// <param name="deviceId">The device id.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles) + { + return KillTranscodingJobs( + j => string.IsNullOrWhiteSpace(playSessionId) + ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) + : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), + deleteFiles); + } - await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); - } + /// <summary> + /// Kills the transcoding jobs. + /// </summary> + /// <param name="killJob">The kill job.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles) + { + var jobs = new List<TranscodingJobDto>(); - /// <summary> - /// Kills the single transcoding job. - /// </summary> - /// <param name="deviceId">The device id.</param> - /// <param name="playSessionId">The play session identifier.</param> - /// <param name="deleteFiles">The delete files.</param> - /// <returns>Task.</returns> - public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles) + lock (_activeTranscodingJobs) { - return KillTranscodingJobs( - j => string.IsNullOrWhiteSpace(playSessionId) - ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) - : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), - deleteFiles); + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably. + jobs.AddRange(_activeTranscodingJobs.Where(killJob)); } - /// <summary> - /// Kills the transcoding jobs. - /// </summary> - /// <param name="killJob">The kill job.</param> - /// <param name="deleteFiles">The delete files.</param> - /// <returns>Task.</returns> - private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles) + if (jobs.Count == 0) { - var jobs = new List<TranscodingJobDto>(); + return Task.CompletedTask; + } - lock (_activeTranscodingJobs) + IEnumerable<Task> GetKillJobs() + { + foreach (var job in jobs) { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably. - jobs.AddRange(_activeTranscodingJobs.Where(killJob)); + yield return KillTranscodingJob(job, false, deleteFiles); } + } - if (jobs.Count == 0) - { - return Task.CompletedTask; - } + return Task.WhenAll(GetKillJobs()); + } - IEnumerable<Task> GetKillJobs() - { - foreach (var job in jobs) - { - yield return KillTranscodingJob(job, false, deleteFiles); - } - } + /// <summary> + /// Kills the transcoding job. + /// </summary> + /// <param name="job">The job.</param> + /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param> + /// <param name="delete">The delete.</param> + private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete) + { + job.DisposeKillTimer(); - return Task.WhenAll(GetKillJobs()); - } + _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - /// <summary> - /// Kills the transcoding job. - /// </summary> - /// <param name="job">The job.</param> - /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param> - /// <param name="delete">The delete.</param> - private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete) + lock (_activeTranscodingJobs) { - job.DisposeKillTimer(); - - _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + _activeTranscodingJobs.Remove(job); - lock (_activeTranscodingJobs) + if (job.CancellationTokenSource?.IsCancellationRequested == false) { - _activeTranscodingJobs.Remove(job); - - if (job.CancellationTokenSource?.IsCancellationRequested == false) - { - job.CancellationTokenSource.Cancel(); - } + job.CancellationTokenSource.Cancel(); } + } - lock (_transcodingLocks) - { - _transcodingLocks.Remove(job.Path!); - } + lock (_transcodingLocks) + { + _transcodingLocks.Remove(job.Path!); + } - lock (job.ProcessLock!) - { - #pragma warning disable CA1849 // Can't await in lock block - job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); + lock (job.ProcessLock!) + { +#pragma warning disable CA1849 // Can't await in lock block + job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); - var process = job.Process; + var process = job.Process; - var hasExited = job.HasExited; + var hasExited = job.HasExited; - if (!hasExited) + if (!hasExited) + { + try { - try - { - _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); + _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); - process!.StandardInput.WriteLine("q"); + process!.StandardInput.WriteLine("q"); - // Need to wait because killing is asynchronous. - if (!process.WaitForExit(5000)) - { - _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); - process.Kill(); - } - } - catch (InvalidOperationException) + // Need to wait because killing is asynchronous. + if (!process.WaitForExit(5000)) { + _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); + process.Kill(); } } - #pragma warning restore CA1849 - } - - if (delete(job.Path!)) - { - await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); + catch (InvalidOperationException) + { + } } +#pragma warning restore CA1849 + } - if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) + if (delete(job.Path!)) + { + await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); + if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay) { - try + var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat"); + if (File.Exists(concatFilePath)) { - await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); + _logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath); + File.Delete(concatFilePath); } } } - private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) { - if (retryCount >= 10) + try { - return; + await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); + } + } + } - _logger.LogInformation("Deleting partial stream file(s) {Path}", path); + private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + { + if (retryCount >= 10) + { + return; + } - await Task.Delay(delayMs).ConfigureAwait(false); + _logger.LogInformation("Deleting partial stream file(s) {Path}", path); - try - { - if (jobType == TranscodingJobType.Progressive) - { - DeleteProgressivePartialStreamFiles(path); - } - else - { - DeleteHlsPartialStreamFiles(path); - } - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + await Task.Delay(delayMs).ConfigureAwait(false); - await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); + try + { + if (jobType == TranscodingJobType.Progressive) + { + DeleteProgressivePartialStreamFiles(path); } - catch (Exception ex) + else { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + DeleteHlsPartialStreamFiles(path); } } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - /// <summary> - /// Deletes the progressive partial stream files. - /// </summary> - /// <param name="outputFilePath">The output file path.</param> - private void DeleteProgressivePartialStreamFiles(string outputFilePath) + await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); + } + catch (Exception ex) { - if (File.Exists(outputFilePath)) - { - _fileSystem.DeleteFile(outputFilePath); - } + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); } + } - /// <summary> - /// Deletes the HLS partial stream files. - /// </summary> - /// <param name="outputFilePath">The output file path.</param> - private void DeleteHlsPartialStreamFiles(string outputFilePath) + /// <summary> + /// Deletes the progressive partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteProgressivePartialStreamFiles(string outputFilePath) + { + if (File.Exists(outputFilePath)) { - var directory = Path.GetDirectoryName(outputFilePath) - ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + _fileSystem.DeleteFile(outputFilePath); + } + } - var name = Path.GetFileNameWithoutExtension(outputFilePath); + /// <summary> + /// Deletes the HLS partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteHlsPartialStreamFiles(string outputFilePath) + { + var directory = Path.GetDirectoryName(outputFilePath) + ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + + var name = Path.GetFileNameWithoutExtension(outputFilePath); - var filesToDelete = _fileSystem.GetFilePaths(directory) - .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1); + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1); - List<Exception>? exs = null; - foreach (var file in filesToDelete) + List<Exception>? exs = null; + foreach (var file in filesToDelete) + { + try { - try - { - _logger.LogDebug("Deleting HLS file {0}", file); - _fileSystem.DeleteFile(file); - } - catch (IOException ex) - { - (exs ??= new List<Exception>(4)).Add(ex); - _logger.LogError(ex, "Error deleting HLS file {Path}", file); - } + _logger.LogDebug("Deleting HLS file {0}", file); + _fileSystem.DeleteFile(file); } - - if (exs is not null) + catch (IOException ex) { - throw new AggregateException("Error deleting HLS files", exs); + (exs ??= new List<Exception>(4)).Add(ex); + _logger.LogError(ex, "Error deleting HLS file {Path}", file); } } - /// <summary> - /// Report the transcoding progress to the session manager. - /// </summary> - /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param> - /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param> - /// <param name="transcodingPosition">The current transcoding position.</param> - /// <param name="framerate">The framerate of the transcoding job.</param> - /// <param name="percentComplete">The completion percentage of the transcode.</param> - /// <param name="bytesTranscoded">The number of bytes transcoded.</param> - /// <param name="bitRate">The bitrate of the transcoding job.</param> - public void ReportTranscodingProgress( - TranscodingJobDto job, - StreamState state, - TimeSpan? transcodingPosition, - float? framerate, - double? percentComplete, - long? bytesTranscoded, - int? bitRate) - { - var ticks = transcodingPosition?.Ticks; + if (exs is not null) + { + throw new AggregateException("Error deleting HLS files", exs); + } + } - if (job is not null) - { - job.Framerate = framerate; - job.CompletionPercentage = percentComplete; - job.TranscodingPositionTicks = ticks; - job.BytesTranscoded = bytesTranscoded; - job.BitRate = bitRate; - } + /// <summary> + /// Report the transcoding progress to the session manager. + /// </summary> + /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param> + /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param> + /// <param name="transcodingPosition">The current transcoding position.</param> + /// <param name="framerate">The framerate of the transcoding job.</param> + /// <param name="percentComplete">The completion percentage of the transcode.</param> + /// <param name="bytesTranscoded">The number of bytes transcoded.</param> + /// <param name="bitRate">The bitrate of the transcoding job.</param> + public void ReportTranscodingProgress( + TranscodingJobDto job, + StreamState state, + TimeSpan? transcodingPosition, + float? framerate, + double? percentComplete, + long? bytesTranscoded, + int? bitRate) + { + var ticks = transcodingPosition?.Ticks; - var deviceId = state.Request.DeviceId; + if (job is not null) + { + job.Framerate = framerate; + job.CompletionPercentage = percentComplete; + job.TranscodingPositionTicks = ticks; + job.BytesTranscoded = bytesTranscoded; + job.BitRate = bitRate; + } - if (!string.IsNullOrWhiteSpace(deviceId)) - { - var audioCodec = state.ActualOutputAudioCodec; - var videoCodec = state.ActualOutputVideoCodec; - var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; - HardwareEncodingType? hardwareAccelerationType = null; - if (!string.IsNullOrEmpty(hardwareAccelerationTypeString) - && Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType)) - { - hardwareAccelerationType = parsedHardwareAccelerationType; - } + var deviceId = state.Request.DeviceId; - _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo - { - Bitrate = bitRate ?? state.TotalOutputBitrate, - AudioCodec = audioCodec, - VideoCodec = videoCodec, - Container = state.OutputContainer, - Framerate = framerate, - CompletionPercentage = percentComplete, - Width = state.OutputWidth, - Height = state.OutputHeight, - AudioChannels = state.OutputAudioChannels, - IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), - IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), - HardwareAccelerationType = hardwareAccelerationType, - TranscodeReasons = state.TranscodeReasons - }); - } + if (!string.IsNullOrWhiteSpace(deviceId)) + { + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; + HardwareEncodingType? hardwareAccelerationType = null; + if (Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType)) + { + hardwareAccelerationType = parsedHardwareAccelerationType; + } + + _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo + { + Bitrate = bitRate ?? state.TotalOutputBitrate, + AudioCodec = audioCodec, + VideoCodec = videoCodec, + Container = state.OutputContainer, + Framerate = framerate, + CompletionPercentage = percentComplete, + Width = state.OutputWidth, + Height = state.OutputHeight, + AudioChannels = state.OutputAudioChannels, + IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), + IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), + HardwareAccelerationType = hardwareAccelerationType, + TranscodeReasons = state.TranscodeReasons + }); } + } - /// <summary> - /// Starts FFmpeg. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="outputPath">The output path.</param> - /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param> - /// <param name="request">The <see cref="HttpRequest"/>.</param> - /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <param name="workingDirectory">The working directory.</param> - /// <returns>Task.</returns> - public async Task<TranscodingJobDto> StartFfMpeg( - StreamState state, - string outputPath, - string commandLineArguments, - HttpRequest request, - TranscodingJobType transcodingJobType, - CancellationTokenSource cancellationTokenSource, - string? workingDirectory = null) - { - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - Directory.CreateDirectory(directory); - - await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); - - if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + /// <summary> + /// Starts FFmpeg. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param> + /// <param name="request">The <see cref="HttpRequest"/>.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <param name="workingDirectory">The working directory.</param> + /// <returns>Task.</returns> + public async Task<TranscodingJobDto> StartFfMpeg( + StreamState state, + string outputPath, + string commandLineArguments, + HttpRequest request, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource, + string? workingDirectory = null) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); + + await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); + + if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + var userId = request.HttpContext.User.GetUserId(); + var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); + if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) { - var userId = request.HttpContext.User.GetUserId(); - var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); - if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) - { - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - throw new ArgumentException("User does not have access to video transcoding."); - } + throw new ArgumentException("User does not have access to video transcoding."); } + } - ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); + ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); - // If subtitles get burned in fonts may need to be extracted from the media file - if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + // If subtitles get burned in fonts may need to be extracted from the media file + if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + { + var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); + if (state.VideoType != VideoType.Dvd) { - var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); - - if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase)) - { - string subtitlePath = state.SubtitleStream.Path; - string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); - string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture); - - await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); - } } - var process = new Process + if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase)) { - StartInfo = new ProcessStartInfo - { - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - UseShellExecute = false, - - // Must consume both stdout and stderr or deadlocks may occur - // RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - FileName = _mediaEncoder.EncoderPath, - Arguments = commandLineArguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, - ErrorDialog = false - }, - EnableRaisingEvents = true - }; + string subtitlePath = state.SubtitleStream.Path; + string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); + string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture); - var transcodingJob = this.OnTranscodeBeginning( - outputPath, - state.Request.PlaySessionId, - state.MediaSource.LiveStreamId, - Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - transcodingJobType, - process, - state.Request.DeviceId, - state, - cancellationTokenSource); - - _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); - - var logFilePrefix = "FFmpeg.Transcode-"; - if (state.VideoRequest is not null - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "FFmpeg.Remux-" - : "FFmpeg.DirectStream-"; + await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); } + } - var logFilePath = Path.Combine( - _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, - $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); + var process = new Process + { + StartInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both stdout and stderr or deadlocks may occur + // RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + FileName = _mediaEncoder.EncoderPath, + Arguments = commandLineArguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, + ErrorDialog = false + }, + EnableRaisingEvents = true + }; + + var transcodingJob = this.OnTranscodeBeginning( + outputPath, + state.Request.PlaySessionId, + state.MediaSource.LiveStreamId, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + transcodingJobType, + process, + state.Request.DeviceId, + state, + cancellationTokenSource); + + _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + var logFilePrefix = "FFmpeg.Transcode-"; + if (state.VideoRequest is not null + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) + ? "FFmpeg.Remux-" + : "FFmpeg.DirectStream-"; + } - // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + var logFilePath = Path.Combine( + _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, + $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); - var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; - var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); - await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false); + // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); + await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false); - try - { - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting FFmpeg"); + process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting FFmpeg"); - throw; - } + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - _logger.LogDebug("Launched FFmpeg process"); - state.TranscodingJob = transcodingJob; + throw; + } - // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback - _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); + _logger.LogDebug("Launched FFmpeg process"); + state.TranscodingJob = transcodingJob; - // Wait for the file to exist before proceeding - var ffmpegTargetFile = state.WaitForPath ?? outputPath; - _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); - while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) - { - await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); - } + // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback + _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); - _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); + // Wait for the file to exist before proceeding + var ffmpegTargetFile = state.WaitForPath ?? outputPath; + _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); + while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) + { + await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); + } - if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) - { - await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); + _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); - if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) - { - await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); - } - } + if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) + { + await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); - if (!transcodingJob.HasExited) + if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) { - StartThrottler(state, transcodingJob); + await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); } - else if (transcodingJob.ExitCode != 0) - { - throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode)); - } - - _logger.LogDebug("StartFfMpeg() finished successfully"); + } - return transcodingJob; + if (!transcodingJob.HasExited) + { + StartThrottler(state, transcodingJob); } + else if (transcodingJob.ExitCode != 0) + { + throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode)); + } + + _logger.LogDebug("StartFfMpeg() finished successfully"); - private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob) + return transcodingJob; + } + + private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob) + { + if (EnableThrottling(state)) { - if (EnableThrottling(state)) - { - transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger<TranscodingThrottler>(new LoggerFactory()), _serverConfigurationManager, _fileSystem, _mediaEncoder); - transcodingJob.TranscodingThrottler.Start(); - } + transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, _loggerFactory.CreateLogger<TranscodingThrottler>(), _serverConfigurationManager, _fileSystem, _mediaEncoder); + transcodingJob.TranscodingThrottler.Start(); } + } - private bool EnableThrottling(StreamState state) + private bool EnableThrottling(StreamState state) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + + return state.InputProtocol == MediaProtocol.File && + state.RunTimeTicks.HasValue && + state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && + state.IsInputVideo && + state.VideoType == VideoType.VideoFile; + } + + /// <summary> + /// Called when [transcode beginning]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="liveStreamId">The live stream identifier.</param> + /// <param name="transcodingJobId">The transcoding job identifier.</param> + /// <param name="type">The type.</param> + /// <param name="process">The process.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="state">The state.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <returns>TranscodingJob.</returns> + public TranscodingJobDto OnTranscodeBeginning( + string path, + string? playSessionId, + string? liveStreamId, + string transcodingJobId, + TranscodingJobType type, + Process process, + string? deviceId, + StreamState state, + CancellationTokenSource cancellationTokenSource) + { + lock (_activeTranscodingJobs) { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>()) + { + Type = type, + Path = path, + Process = process, + ActiveRequestCount = 1, + DeviceId = deviceId, + CancellationTokenSource = cancellationTokenSource, + Id = transcodingJobId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + MediaSource = state.MediaSource + }; - return state.InputProtocol == MediaProtocol.File && - state.RunTimeTicks.HasValue && - state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && - state.IsInputVideo && - state.VideoType == VideoType.VideoFile; - } - - /// <summary> - /// Called when [transcode beginning]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="playSessionId">The play session identifier.</param> - /// <param name="liveStreamId">The live stream identifier.</param> - /// <param name="transcodingJobId">The transcoding job identifier.</param> - /// <param name="type">The type.</param> - /// <param name="process">The process.</param> - /// <param name="deviceId">The device id.</param> - /// <param name="state">The state.</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <returns>TranscodingJob.</returns> - public TranscodingJobDto OnTranscodeBeginning( - string path, - string? playSessionId, - string? liveStreamId, - string transcodingJobId, - TranscodingJobType type, - Process process, - string? deviceId, - StreamState state, - CancellationTokenSource cancellationTokenSource) - { - lock (_activeTranscodingJobs) - { - var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>()) - { - Type = type, - Path = path, - Process = process, - ActiveRequestCount = 1, - DeviceId = deviceId, - CancellationTokenSource = cancellationTokenSource, - Id = transcodingJobId, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - MediaSource = state.MediaSource - }; - - _activeTranscodingJobs.Add(job); - - ReportTranscodingProgress(job, state, null, null, null, null, null); - - return job; - } + _activeTranscodingJobs.Add(job); + + ReportTranscodingProgress(job, state, null, null, null, null, null); + + return job; } + } - /// <summary> - /// Called when [transcode end]. - /// </summary> - /// <param name="job">The transcode job.</param> - public void OnTranscodeEndRequest(TranscodingJobDto job) + /// <summary> + /// Called when [transcode end]. + /// </summary> + /// <param name="job">The transcode job.</param> + public void OnTranscodeEndRequest(TranscodingJobDto job) + { + job.ActiveRequestCount--; + _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); + if (job.ActiveRequestCount <= 0) { - job.ActiveRequestCount--; - _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); - if (job.ActiveRequestCount <= 0) - { - PingTimer(job, false); - } + PingTimer(job, false); } + } - /// <summary> - /// <summary> - /// The progressive - /// </summary> - /// Called when [transcode failed to start]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="type">The type.</param> - /// <param name="state">The state.</param> - public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + /// <summary> + /// <summary> + /// The progressive + /// </summary> + /// Called when [transcode failed to start]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <param name="state">The state.</param> + public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + { + lock (_activeTranscodingJobs) { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - - if (job is not null) - { - _activeTranscodingJobs.Remove(job); - } - } + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - lock (_transcodingLocks) + if (job is not null) { - _transcodingLocks.Remove(path); + _activeTranscodingJobs.Remove(job); } + } - if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) - { - _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); - } + lock (_transcodingLocks) + { + _transcodingLocks.Remove(path); } - /// <summary> - /// Processes the exited. - /// </summary> - /// <param name="process">The process.</param> - /// <param name="job">The job.</param> - /// <param name="state">The state.</param> - private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) + if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) { - job.HasExited = true; - job.ExitCode = process.ExitCode; + _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); + } + } - ReportTranscodingProgress(job, state, null, null, null, null, null); + /// <summary> + /// Processes the exited. + /// </summary> + /// <param name="process">The process.</param> + /// <param name="job">The job.</param> + /// <param name="state">The state.</param> + private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) + { + job.HasExited = true; + job.ExitCode = process.ExitCode; - _logger.LogDebug("Disposing stream resources"); - state.Dispose(); + ReportTranscodingProgress(job, state, null, null, null, null, null); - if (process.ExitCode == 0) - { - _logger.LogInformation("FFmpeg exited with code 0"); - } - else - { - _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); - } + _logger.LogDebug("Disposing stream resources"); + state.Dispose(); - job.Dispose(); + if (process.ExitCode == 0) + { + _logger.LogInformation("FFmpeg exited with code 0"); } - - private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + else { - if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) - { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( - new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, - cancellationTokenSource.Token) - .ConfigureAwait(false); - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); + } - _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); + job.Dispose(); + } - if (state.VideoRequest is not null) - { - _encodingHelper.TryStreamCopy(state); - } - } + private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + { + if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( + new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, + cancellationTokenSource.Token) + .ConfigureAwait(false); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + + _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); - if (state.MediaSource.BufferMs.HasValue) + if (state.VideoRequest is not null) { - await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); + _encodingHelper.TryStreamCopy(state); } } - /// <summary> - /// Called when [transcode begin request]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="type">The type.</param> - /// <returns>The <see cref="TranscodingJobDto"/>.</returns> - public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type) + if (state.MediaSource.BufferMs.HasValue) { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - - if (job is null) - { - return null; - } - - OnTranscodeBeginRequest(job); - - return job; - } + await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); } + } - private void OnTranscodeBeginRequest(TranscodingJobDto job) + /// <summary> + /// Called when [transcode begin request]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <returns>The <see cref="TranscodingJobDto"/>.</returns> + public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) { - job.ActiveRequestCount++; + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + if (job is null) { - job.StopKillTimer(); + return null; } + + OnTranscodeBeginRequest(job); + + return job; } + } - /// <summary> - /// Gets the transcoding lock. - /// </summary> - /// <param name="outputPath">The output path of the transcoded file.</param> - /// <returns>A <see cref="SemaphoreSlim"/>.</returns> - public SemaphoreSlim GetTranscodingLock(string outputPath) - { - lock (_transcodingLocks) - { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) - { - result = new SemaphoreSlim(1, 1); - _transcodingLocks[outputPath] = result; - } + private void OnTranscodeBeginRequest(TranscodingJobDto job) + { + job.ActiveRequestCount++; - return result; - } + if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + { + job.StopKillTimer(); } + } - private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) + /// <summary> + /// Gets the transcoding lock. + /// </summary> + /// <param name="outputPath">The output path of the transcoded file.</param> + /// <returns>A <see cref="SemaphoreSlim"/>.</returns> + public SemaphoreSlim GetTranscodingLock(string outputPath) + { + lock (_transcodingLocks) { - if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) { - PingTranscodingJob(e.PlaySessionId, e.IsPaused); + result = new SemaphoreSlim(1, 1); + _transcodingLocks[outputPath] = result; } + + return result; } + } - /// <summary> - /// Deletes the encoded media cache. - /// </summary> - private void DeleteEncodedMediaCache() + private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) + { + if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) { - var path = _serverConfigurationManager.GetTranscodePath(); - if (!Directory.Exists(path)) - { - return; - } + PingTranscodingJob(e.PlaySessionId, e.IsPaused); + } + } - foreach (var file in _fileSystem.GetFilePaths(path, true)) - { - _fileSystem.DeleteFile(file); - } + /// <summary> + /// Deletes the encoded media cache. + /// </summary> + private void DeleteEncodedMediaCache() + { + var path = _serverConfigurationManager.GetTranscodePath(); + if (!Directory.Exists(path)) + { + return; } - /// <summary> - /// Dispose transcoding job helper. - /// </summary> - public void Dispose() + foreach (var file in _fileSystem.GetFilePaths(path, true)) { - Dispose(true); - GC.SuppressFinalize(this); + _fileSystem.DeleteFile(file); } + } - /// <summary> - /// Dispose throttler. - /// </summary> - /// <param name="disposing">Disposing.</param> - protected virtual void Dispose(bool disposing) + /// <summary> + /// Dispose transcoding job helper. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + /// <param name="disposing">Disposing.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { - if (disposing) - { - _loggerFactory.Dispose(); - _sessionManager.PlaybackProgress -= OnPlaybackProgress; - _sessionManager.PlaybackStart -= OnPlaybackProgress; - } + _loggerFactory.Dispose(); + _sessionManager.PlaybackProgress -= OnPlaybackProgress; + _sessionManager.PlaybackStart -= OnPlaybackProgress; } } } diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 45725ec3e6..6a0a4706be 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -13,27 +13,28 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="7.0.2" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" /> - <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" /> - <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.4.0" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" /> + <PackageReference Include="Microsoft.Extensions.Http" /> + <PackageReference Include="Swashbuckle.AspNetCore" /> + <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> + <ProjectReference Include="..\MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj" /> <ProjectReference Include="..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" /> </ItemGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs index 6bd9e0b084..2241c68e7a 100644 --- a/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs +++ b/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs @@ -7,75 +7,72 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Redirect requests without baseurl prefix to the baseurl prefixed URL. +/// </summary> +public class BaseUrlRedirectionMiddleware { + private readonly RequestDelegate _next; + private readonly ILogger<BaseUrlRedirectionMiddleware> _logger; + private readonly IConfiguration _configuration; + /// <summary> - /// Redirect requests without baseurl prefix to the baseurl prefixed URL. + /// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class. /// </summary> - public class BaseUrlRedirectionMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + /// <param name="configuration">The application configuration.</param> + public BaseUrlRedirectionMiddleware( + RequestDelegate next, + ILogger<BaseUrlRedirectionMiddleware> logger, + IConfiguration configuration) { - private readonly RequestDelegate _next; - private readonly ILogger<BaseUrlRedirectionMiddleware> _logger; - private readonly IConfiguration _configuration; + _next = next; + _logger = logger; + _configuration = configuration; + } - /// <summary> - /// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - /// <param name="logger">The logger.</param> - /// <param name="configuration">The application configuration.</param> - public BaseUrlRedirectionMiddleware( - RequestDelegate next, - ILogger<BaseUrlRedirectionMiddleware> logger, - IConfiguration configuration) - { - _next = next; - _logger = logger; - _configuration = configuration; - } + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager) + { + var localPath = httpContext.Request.Path.ToString(); + var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl; - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager) + if (string.IsNullOrEmpty(localPath) + || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase) + || string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase) + || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase) + ) { - var localPath = httpContext.Request.Path.ToString(); - var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl; - - if (string.IsNullOrEmpty(localPath) - || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, baseUrlPrefix + "/web", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, baseUrlPrefix + "/web/", StringComparison.OrdinalIgnoreCase) - || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase) - ) + // Redirect health endpoint + if (string.Equals(localPath, "/health", StringComparison.OrdinalIgnoreCase) + || string.Equals(localPath, "/health/", StringComparison.OrdinalIgnoreCase)) { - // Redirect health endpoint - if (string.Equals(localPath, "/health", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, "/health/", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Redirecting /health check"); - httpContext.Response.Redirect(baseUrlPrefix + "/health"); - return; - } - - // Always redirect back to the default path if the base prefix is invalid or missing - _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); - - var port = httpContext.Request.Host.Port ?? -1; - var uri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, localPath).Uri; - var redirectUri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]).Uri; - var target = uri.MakeRelativeUri(redirectUri).ToString(); - _logger.LogDebug("Redirecting to {Target}", target); - - httpContext.Response.Redirect(target); + _logger.LogDebug("Redirecting /health check"); + httpContext.Response.Redirect(baseUrlPrefix + "/health"); return; } - await _next(httpContext).ConfigureAwait(false); + // Always redirect back to the default path if the base prefix is invalid or missing + _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); + + var port = httpContext.Request.Host.Port ?? -1; + var uri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, localPath).Uri; + var redirectUri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]).Uri; + var target = uri.MakeRelativeUri(redirectUri).ToString(); + _logger.LogDebug("Redirecting to {Target}", target); + + httpContext.Response.Redirect(target); + return; } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs index 6b3aeb187a..060c14f89d 100644 --- a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs @@ -12,140 +12,139 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Exception Middleware. +/// </summary> +public class ExceptionMiddleware { + private readonly RequestDelegate _next; + private readonly ILogger<ExceptionMiddleware> _logger; + private readonly IServerConfigurationManager _configuration; + private readonly IWebHostEnvironment _hostEnvironment; + /// <summary> - /// Exception Middleware. + /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class. /// </summary> - public class ExceptionMiddleware + /// <param name="next">Next request delegate.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param> + public ExceptionMiddleware( + RequestDelegate next, + ILogger<ExceptionMiddleware> logger, + IServerConfigurationManager serverConfigurationManager, + IWebHostEnvironment hostEnvironment) { - private readonly RequestDelegate _next; - private readonly ILogger<ExceptionMiddleware> _logger; - private readonly IServerConfigurationManager _configuration; - private readonly IWebHostEnvironment _hostEnvironment; + _next = next; + _logger = logger; + _configuration = serverConfigurationManager; + _hostEnvironment = hostEnvironment; + } - /// <summary> - /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class. - /// </summary> - /// <param name="next">Next request delegate.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param> - public ExceptionMiddleware( - RequestDelegate next, - ILogger<ExceptionMiddleware> logger, - IServerConfigurationManager serverConfigurationManager, - IWebHostEnvironment hostEnvironment) + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <returns>Task.</returns> + public async Task Invoke(HttpContext context) + { + try { - _next = next; - _logger = logger; - _configuration = serverConfigurationManager; - _hostEnvironment = hostEnvironment; + await _next(context).ConfigureAwait(false); } - - /// <summary> - /// Invoke request. - /// </summary> - /// <param name="context">Request context.</param> - /// <returns>Task.</returns> - public async Task Invoke(HttpContext context) + catch (Exception ex) { - try + if (context.Response.HasStarted) { - await _next(context).ConfigureAwait(false); + _logger.LogWarning("The response has already started, the exception middleware will not be executed."); + throw; } - catch (Exception ex) - { - if (context.Response.HasStarted) - { - _logger.LogWarning("The response has already started, the exception middleware will not be executed."); - throw; - } - ex = GetActualException(ex); + ex = GetActualException(ex); - bool ignoreStackTrace = - ex is SocketException - || ex is IOException - || ex is OperationCanceledException - || ex is SecurityException - || ex is AuthenticationException - || ex is FileNotFoundException; + bool ignoreStackTrace = + ex is SocketException + || ex is IOException + || ex is OperationCanceledException + || ex is SecurityException + || ex is AuthenticationException + || ex is FileNotFoundException; - if (ignoreStackTrace) - { - _logger.LogError( - "Error processing request: {ExceptionMessage}. URL {Method} {Url}.", - ex.Message.TrimEnd('.'), - context.Request.Method, - context.Request.Path); - } - else - { - _logger.LogError( - ex, - "Error processing request. URL {Method} {Url}.", - context.Request.Method, - context.Request.Path); - } + if (ignoreStackTrace) + { + _logger.LogError( + "Error processing request: {ExceptionMessage}. URL {Method} {Url}.", + ex.Message.TrimEnd('.'), + context.Request.Method, + context.Request.Path); + } + else + { + _logger.LogError( + ex, + "Error processing request. URL {Method} {Url}.", + context.Request.Method, + context.Request.Path); + } - context.Response.StatusCode = GetStatusCode(ex); - context.Response.ContentType = MediaTypeNames.Text.Plain; + context.Response.StatusCode = GetStatusCode(ex); + context.Response.ContentType = MediaTypeNames.Text.Plain; - // Don't send exception unless the server is in a Development environment - var errorContent = _hostEnvironment.IsDevelopment() - ? NormalizeExceptionMessage(ex.Message) - : "Error processing request."; - await context.Response.WriteAsync(errorContent).ConfigureAwait(false); - } + // Don't send exception unless the server is in a Development environment + var errorContent = _hostEnvironment.IsDevelopment() + ? NormalizeExceptionMessage(ex.Message) + : "Error processing request."; + await context.Response.WriteAsync(errorContent).ConfigureAwait(false); } + } - private static Exception GetActualException(Exception ex) + private static Exception GetActualException(Exception ex) + { + if (ex is AggregateException agg) { - if (ex is AggregateException agg) + var inner = agg.InnerException; + if (inner is not null) { - var inner = agg.InnerException; - if (inner is not null) - { - return GetActualException(inner); - } - - var inners = agg.InnerExceptions; - if (inners.Count > 0) - { - return GetActualException(inners[0]); - } + return GetActualException(inner); } - return ex; - } - - private static int GetStatusCode(Exception ex) - { - switch (ex) + var inners = agg.InnerExceptions; + if (inners.Count > 0) { - case ArgumentException _: return StatusCodes.Status400BadRequest; - case AuthenticationException _: return StatusCodes.Status401Unauthorized; - case SecurityException _: return StatusCodes.Status403Forbidden; - case DirectoryNotFoundException _: - case FileNotFoundException _: - case ResourceNotFoundException _: return StatusCodes.Status404NotFound; - case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; - default: return StatusCodes.Status500InternalServerError; + return GetActualException(inners[0]); } } - private string NormalizeExceptionMessage(string msg) + return ex; + } + + private static int GetStatusCode(Exception ex) + { + switch (ex) { - // Strip any information we don't want to reveal - return msg.Replace( - _configuration.ApplicationPaths.ProgramSystemPath, - string.Empty, - StringComparison.OrdinalIgnoreCase) - .Replace( - _configuration.ApplicationPaths.ProgramDataPath, - string.Empty, - StringComparison.OrdinalIgnoreCase); + case ArgumentException _: return StatusCodes.Status400BadRequest; + case AuthenticationException _: return StatusCodes.Status401Unauthorized; + case SecurityException _: return StatusCodes.Status403Forbidden; + case DirectoryNotFoundException _: + case FileNotFoundException _: + case ResourceNotFoundException _: return StatusCodes.Status404NotFound; + case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; + default: return StatusCodes.Status500InternalServerError; } } + + private string NormalizeExceptionMessage(string msg) + { + // Strip any information we don't want to reveal + return msg.Replace( + _configuration.ApplicationPaths.ProgramSystemPath, + string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace( + _configuration.ApplicationPaths.ProgramDataPath, + string.Empty, + StringComparison.OrdinalIgnoreCase); + } } diff --git a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs index f7af91e489..f45b6b5c0a 100644 --- a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -4,47 +4,46 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Validates the IP of requests coming from local networks wrt. remote access. +/// </summary> +public class IpBasedAccessValidationMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Validates the IP of requests coming from local networks wrt. remote access. + /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class. /// </summary> - public class IpBasedAccessValidationMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public IpBasedAccessValidationMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public IpBasedAccessValidationMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="networkManager">The network manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, INetworkManager networkManager) + { + if (httpContext.IsLocal()) { - _next = next; + // Running locally. + await _next(httpContext).ConfigureAwait(false); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="networkManager">The network manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, INetworkManager networkManager) - { - if (httpContext.IsLocal()) - { - // Running locally. - await _next(httpContext).ConfigureAwait(false); - return; - } - - var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - - if (!networkManager.HasRemoteAccess(remoteIp)) - { - return; - } + var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - await _next(httpContext).ConfigureAwait(false); + if (!networkManager.HasRemoteAccess(remoteIp)) + { + return; } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs index 18f13bbced..9c2194fafd 100644 --- a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs +++ b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs @@ -1,45 +1,49 @@ -using System.Net; using System.Threading.Tasks; using Jellyfin.Networking.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Validates the LAN host IP based on application configuration. +/// </summary> +public class LanFilteringMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Validates the LAN host IP based on application configuration. + /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class. /// </summary> - public class LanFilteringMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public LanFilteringMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public LanFilteringMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="networkManager">The network manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager) + { + if (serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess) { - _next = next; + await _next(httpContext).ConfigureAwait(false); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="networkManager">The network manager.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager) + var host = httpContext.GetNormalizedRemoteIp(); + if (!networkManager.IsInLocalNetwork(host)) { - var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - - if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess) - { - return; - } - - await _next(httpContext).ConfigureAwait(false); + return; } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs index b73923c1e5..17d8997d56 100644 --- a/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs +++ b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs @@ -3,52 +3,51 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Removes /emby and /mediabrowser from requested route. +/// </summary> +public class LegacyEmbyRouteRewriteMiddleware { + private const string EmbyPath = "/emby"; + private const string MediabrowserPath = "/mediabrowser"; + + private readonly RequestDelegate _next; + private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger; + /// <summary> - /// Removes /emby and /mediabrowser from requested route. + /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class. /// </summary> - public class LegacyEmbyRouteRewriteMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public LegacyEmbyRouteRewriteMiddleware( + RequestDelegate next, + ILogger<LegacyEmbyRouteRewriteMiddleware> logger) { - private const string EmbyPath = "/emby"; - private const string MediabrowserPath = "/mediabrowser"; - - private readonly RequestDelegate _next; - private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger; + _next = next; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - /// <param name="logger">The logger.</param> - public LegacyEmbyRouteRewriteMiddleware( - RequestDelegate next, - ILogger<LegacyEmbyRouteRewriteMiddleware> logger) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase)) { - _next = next; - _logger = logger; + httpContext.Request.Path = localPath[EmbyPath.Length..]; + _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath); } - - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext) + else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase)) { - var localPath = httpContext.Request.Path.ToString(); - if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase)) - { - httpContext.Request.Path = localPath[EmbyPath.Length..]; - _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath); - } - else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase)) - { - httpContext.Request.Path = localPath[MediabrowserPath.Length..]; - _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath); - } - - await _next(httpContext).ConfigureAwait(false); + httpContext.Request.Path = localPath[MediabrowserPath.Length..]; + _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath); } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs index 4b6304e0e7..cb4169e991 100644 --- a/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs +++ b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs @@ -2,38 +2,37 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// URL decodes the querystring before binding. +/// </summary> +public class QueryStringDecodingMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// URL decodes the querystring before binding. + /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class. /// </summary> - public class QueryStringDecodingMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public QueryStringDecodingMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public QueryStringDecodingMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var feature = httpContext.Features.Get<IQueryFeature>(); + if (feature is not null) { - _next = next; + httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature)); } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext) - { - var feature = httpContext.Features.Get<IQueryFeature>(); - if (feature is not null) - { - httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature)); - } - - await _next(httpContext).ConfigureAwait(false); - } + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs index 3701d0f451..db39177436 100644 --- a/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs +++ b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs @@ -7,63 +7,62 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Response time middleware. +/// </summary> +public class ResponseTimeMiddleware { + private const string ResponseHeaderResponseTime = "X-Response-Time-ms"; + + private readonly RequestDelegate _next; + private readonly ILogger<ResponseTimeMiddleware> _logger; + /// <summary> - /// Response time middleware. + /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> class. /// </summary> - public class ResponseTimeMiddleware + /// <param name="next">Next request delegate.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> + public ResponseTimeMiddleware( + RequestDelegate next, + ILogger<ResponseTimeMiddleware> logger) { - private const string ResponseHeaderResponseTime = "X-Response-Time-ms"; - - private readonly RequestDelegate _next; - private readonly ILogger<ResponseTimeMiddleware> _logger; + _next = next; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> class. - /// </summary> - /// <param name="next">Next request delegate.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> - public ResponseTimeMiddleware( - RequestDelegate next, - ILogger<ResponseTimeMiddleware> logger) - { - _next = next; - _logger = logger; - } + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <returns>Task.</returns> + public async Task Invoke(HttpContext context, IServerConfigurationManager serverConfigurationManager) + { + var startTimestamp = Stopwatch.GetTimestamp(); - /// <summary> - /// Invoke request. - /// </summary> - /// <param name="context">Request context.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <returns>Task.</returns> - public async Task Invoke(HttpContext context, IServerConfigurationManager serverConfigurationManager) + var enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning; + var warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs; + context.Response.OnStarting(() => { - var startTimestamp = Stopwatch.GetTimestamp(); - - var enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning; - var warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs; - context.Response.OnStarting(() => + var responseTime = Stopwatch.GetElapsedTime(startTimestamp); + var responseTimeMs = responseTime.TotalMilliseconds; + if (enableWarning && responseTimeMs > warningThreshold && _logger.IsEnabled(LogLevel.Debug)) { - var responseTime = Stopwatch.GetElapsedTime(startTimestamp); - var responseTimeMs = responseTime.TotalMilliseconds; - if (enableWarning && responseTimeMs > warningThreshold && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}", - context.Request.GetDisplayUrl(), - context.GetNormalizedRemoteIp(), - responseTime, - context.Response.StatusCode); - } + _logger.LogDebug( + "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}", + context.Request.GetDisplayUrl(), + context.GetNormalizedRemoteIp(), + responseTime, + context.Response.StatusCode); + } - context.Response.Headers[ResponseHeaderResponseTime] = responseTimeMs.ToString(CultureInfo.InvariantCulture); - return Task.CompletedTask; - }); + context.Response.Headers[ResponseHeaderResponseTime] = responseTimeMs.ToString(CultureInfo.InvariantCulture); + return Task.CompletedTask; + }); - // Call the next delegate/middleware in the pipeline - await this._next(context).ConfigureAwait(false); - } + // Call the next delegate/middleware in the pipeline + await this._next(context).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs index 2e69580bee..8bf626035d 100644 --- a/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs +++ b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs @@ -3,45 +3,44 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Redirect requests to robots.txt to web/robots.txt. +/// </summary> +public class RobotsRedirectionMiddleware { + private readonly RequestDelegate _next; + private readonly ILogger<RobotsRedirectionMiddleware> _logger; + /// <summary> - /// Redirect requests to robots.txt to web/robots.txt. + /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class. /// </summary> - public class RobotsRedirectionMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public RobotsRedirectionMiddleware( + RequestDelegate next, + ILogger<RobotsRedirectionMiddleware> logger) { - private readonly RequestDelegate _next; - private readonly ILogger<RobotsRedirectionMiddleware> _logger; + _next = next; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - /// <param name="logger">The logger.</param> - public RobotsRedirectionMiddleware( - RequestDelegate next, - ILogger<RobotsRedirectionMiddleware> logger) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase)) { - _next = next; - _logger = logger; + _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); + httpContext.Response.Redirect("web/robots.txt"); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext) - { - var localPath = httpContext.Request.Path.ToString(); - if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); - httpContext.Response.Redirect("web/robots.txt"); - return; - } - - await _next(httpContext).ConfigureAwait(false); - } + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs index dcd64401a4..dcb2346589 100644 --- a/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs +++ b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs @@ -5,47 +5,46 @@ using MediaBrowser.Controller; using MediaBrowser.Model.Globalization; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Shows a custom message during server startup. +/// </summary> +public class ServerStartupMessageMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Shows a custom message during server startup. + /// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class. /// </summary> - public class ServerStartupMessageMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public ServerStartupMessageMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public ServerStartupMessageMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="serverApplicationHost">The server application host.</param> + /// <param name="localizationManager">The localization manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke( + HttpContext httpContext, + IServerApplicationHost serverApplicationHost, + ILocalizationManager localizationManager) + { + if (serverApplicationHost.CoreStartupHasCompleted + || httpContext.Request.Path.Equals("/system/ping", StringComparison.OrdinalIgnoreCase)) { - _next = next; + await _next(httpContext).ConfigureAwait(false); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="serverApplicationHost">The server application host.</param> - /// <param name="localizationManager">The localization manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke( - HttpContext httpContext, - IServerApplicationHost serverApplicationHost, - ILocalizationManager localizationManager) - { - if (serverApplicationHost.CoreStartupHasCompleted - || httpContext.Request.Path.Equals("/system/ping", StringComparison.OrdinalIgnoreCase)) - { - await _next(httpContext).ConfigureAwait(false); - return; - } - - var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading"); - httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - httpContext.Response.ContentType = MediaTypeNames.Text.Html; - await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false); - } + var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading"); + httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + httpContext.Response.ContentType = MediaTypeNames.Text.Html; + await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs index d35e0fcfd9..f75d0d24e9 100644 --- a/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs +++ b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs @@ -6,79 +6,78 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Defines the <see cref="UrlDecodeQueryFeature"/>. +/// </summary> +public class UrlDecodeQueryFeature : IQueryFeature { + private IQueryCollection? _store; + /// <summary> - /// Defines the <see cref="UrlDecodeQueryFeature"/>. + /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class. /// </summary> - public class UrlDecodeQueryFeature : IQueryFeature + /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param> + public UrlDecodeQueryFeature(IQueryFeature feature) { - private IQueryCollection? _store; + Query = feature.Query; + } - /// <summary> - /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class. - /// </summary> - /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param> - public UrlDecodeQueryFeature(IQueryFeature feature) + /// <summary> + /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. + /// </summary> + public IQueryCollection Query + { + get { - Query = feature.Query; + return _store ?? QueryCollection.Empty; } - /// <summary> - /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. - /// </summary> - public IQueryCollection Query + set { - get + // Only interested in where the querystring is encoded which shows up as one key with nothing in the value. + if (value.Count != 1) { - return _store ?? QueryCollection.Empty; + _store = value; + return; } - set + // Encoded querystrings have no value, so don't process anything if a value is present. + var (key, stringValues) = value.First(); + if (!string.IsNullOrEmpty(stringValues)) { - // Only interested in where the querystring is encoded which shows up as one key with nothing in the value. - if (value.Count != 1) - { - _store = value; - return; - } + _store = value; + return; + } - // Encoded querystrings have no value, so don't process anything if a value is present. - var (key, stringValues) = value.First(); - if (!string.IsNullOrEmpty(stringValues)) - { - _store = value; - return; - } + if (!key.Contains('=', StringComparison.Ordinal)) + { + _store = value; + return; + } - if (!key.Contains('=', StringComparison.Ordinal)) + var pairs = new Dictionary<string, StringValues>(); + foreach (var pair in key.SpanSplit('&')) + { + var i = pair.IndexOf('='); + if (i == -1) { - _store = value; - return; + // encoded is an equals. + // We use TryAdd so duplicate keys get ignored + pairs.TryAdd(pair.ToString(), StringValues.Empty); + continue; } - var pairs = new Dictionary<string, StringValues>(); - foreach (var pair in key.SpanSplit('&')) + var k = pair[..i].ToString(); + var v = pair[(i + 1)..].ToString(); + if (!pairs.TryAdd(k, new StringValues(v))) { - var i = pair.IndexOf('='); - if (i == -1) - { - // encoded is an equals. - // We use TryAdd so duplicate keys get ignored - pairs.TryAdd(pair.ToString(), StringValues.Empty); - continue; - } - - var k = pair[..i].ToString(); - var v = pair[(i + 1)..].ToString(); - if (!pairs.TryAdd(k, new StringValues(v))) - { - pairs[k] = StringValues.Concat(pairs[k], v); - } + pairs[k] = StringValues.Concat(pairs[k], v); } - - _store = new QueryCollection(pairs); } + + _store = new QueryCollection(pairs); } } } diff --git a/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs index 2cf1e5e4aa..009fb6269d 100644 --- a/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs +++ b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs @@ -2,39 +2,38 @@ using System.Threading.Tasks; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Handles WebSocket requests. +/// </summary> +public class WebSocketHandlerMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Handles WebSocket requests. + /// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class. /// </summary> - public class WebSocketHandlerMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public WebSocketHandlerMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public WebSocketHandlerMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="webSocketManager">The WebSocket connection manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager) + { + if (!httpContext.WebSockets.IsWebSocketRequest) { - _next = next; + await _next(httpContext).ConfigureAwait(false); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="webSocketManager">The WebSocket connection manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager) - { - if (!httpContext.WebSockets.IsWebSocketRequest) - { - await _next(httpContext).ConfigureAwait(false); - return; - } - - await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false); - } + await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs index 75e47a71be..a34fd01d5e 100644 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs @@ -5,86 +5,85 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Comma delimited array model binder. +/// Returns an empty array of specified type if there is no query parameter. +/// </summary> +public class CommaDelimitedArrayModelBinder : IModelBinder { + private readonly ILogger<CommaDelimitedArrayModelBinder> _logger; + /// <summary> - /// Comma delimited array model binder. - /// Returns an empty array of specified type if there is no query parameter. + /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class. /// </summary> - public class CommaDelimitedArrayModelBinder : IModelBinder + /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param> + public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + + /// <inheritdoc/> + public Task BindModelAsync(ModelBindingContext bindingContext) { - private readonly ILogger<CommaDelimitedArrayModelBinder> _logger; + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); - /// <summary> - /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param> - public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger) + if (valueProviderResult.Length > 1) { - _logger = logger; + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); } - - /// <inheritdoc/> - public Task BindModelAsync(ModelBindingContext bindingContext) + else { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; - var converter = TypeDescriptor.GetConverter(elementType); + var value = valueProviderResult.FirstValue; - if (valueProviderResult.Length > 1) + if (value is not null) { - var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); bindingContext.Result = ModelBindingResult.Success(typedValues); } else { - var value = valueProviderResult.FirstValue; - - if (value is not null) - { - var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries); - var typedValues = GetParsedResult(splitValues, elementType, converter); - bindingContext.Result = ModelBindingResult.Success(typedValues); - } - else - { - var emptyResult = Array.CreateInstance(elementType, 0); - bindingContext.Result = ModelBindingResult.Success(emptyResult); - } + var emptyResult = Array.CreateInstance(elementType, 0); + bindingContext.Result = ModelBindingResult.Success(emptyResult); } - - return Task.CompletedTask; } - private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + return Task.CompletedTask; + } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) { - var parsedValues = new object?[values.Count]; - var convertedCount = 0; - for (var i = 0; i < values.Count; i++) + try { - try - { - parsedValues[i] = converter.ConvertFromString(values[i].Trim()); - convertedCount++; - } - catch (FormatException e) - { - _logger.LogDebug(e, "Error converting value."); - } + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; } - - var typedValues = Array.CreateInstance(elementType, convertedCount); - var typedValueIndex = 0; - for (var i = 0; i < parsedValues.Length; i++) + catch (FormatException e) { - if (parsedValues[i] != null) - { - typedValues.SetValue(parsedValues[i], typedValueIndex); - typedValueIndex++; - } + _logger.LogDebug(e, "Error converting value."); } + } - return typedValues; + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } } + + return typedValues; } } diff --git a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs index e1cb725f3e..87a30773e5 100644 --- a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs @@ -5,45 +5,44 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// DateTime model binder. +/// </summary> +public class LegacyDateTimeModelBinder : IModelBinder { + // Borrowed from the DateTimeModelBinderProvider + private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces; + private readonly DateTimeModelBinder _defaultModelBinder; + /// <summary> - /// DateTime model binder. + /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class. /// </summary> - public class LegacyDateTimeModelBinder : IModelBinder + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory) { - // Borrowed from the DateTimeModelBinderProvider - private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces; - private readonly DateTimeModelBinder _defaultModelBinder; - - /// <summary> - /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class. - /// </summary> - /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> - public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory) - { - _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory); - } + _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory); + } - /// <inheritdoc /> - public Task BindModelAsync(ModelBindingContext bindingContext) + /// <inheritdoc /> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + if (valueProviderResult.Values.Count == 1) { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - if (valueProviderResult.Values.Count == 1) + var dateTimeString = valueProviderResult.FirstValue; + // Mark Played Item. + if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) { - var dateTimeString = valueProviderResult.FirstValue; - // Mark Played Item. - if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) - { - bindingContext.Result = ModelBindingResult.Success(dateTime); - } - else - { - return _defaultModelBinder.BindModelAsync(bindingContext); - } + bindingContext.Result = ModelBindingResult.Success(dateTime); + } + else + { + return _defaultModelBinder.BindModelAsync(bindingContext); } - - return Task.CompletedTask; } + + return Task.CompletedTask; } } diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs index d2e78ac884..a2e139ca1a 100644 --- a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs @@ -4,45 +4,44 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Nullable enum model binder. +/// </summary> +public class NullableEnumModelBinder : IModelBinder { + private readonly ILogger<NullableEnumModelBinder> _logger; + /// <summary> - /// Nullable enum model binder. + /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class. /// </summary> - public class NullableEnumModelBinder : IModelBinder + /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param> + public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger) { - private readonly ILogger<NullableEnumModelBinder> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param> - public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger) - { - _logger = logger; - } + _logger = logger; + } - /// <inheritdoc /> - public Task BindModelAsync(ModelBindingContext bindingContext) + /// <inheritdoc /> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); + if (valueProviderResult.Length != 0) { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; - var converter = TypeDescriptor.GetConverter(elementType); - if (valueProviderResult.Length != 0) + try { - try - { - // REVIEW: This shouldn't be null here - var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue!); - bindingContext.Result = ModelBindingResult.Success(convertedValue); - } - catch (FormatException e) - { - _logger.LogDebug(e, "Error converting value."); - } + // REVIEW: This shouldn't be null here + var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue!); + bindingContext.Result = ModelBindingResult.Success(convertedValue); + } + catch (FormatException e) + { + _logger.LogDebug(e, "Error converting value."); } - - return Task.CompletedTask; } + + return Task.CompletedTask; } } diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs index da0addd0eb..43ffdaefdc 100644 --- a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs @@ -3,25 +3,24 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Nullable enum model binder provider. +/// </summary> +public class NullableEnumModelBinderProvider : IModelBinderProvider { - /// <summary> - /// Nullable enum model binder provider. - /// </summary> - public class NullableEnumModelBinderProvider : IModelBinderProvider + /// <inheritdoc /> + public IModelBinder? GetBinder(ModelBinderProviderContext context) { - /// <inheritdoc /> - public IModelBinder? GetBinder(ModelBinderProviderContext context) + var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType); + if (nullableType is null || !nullableType.IsEnum) { - var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType); - if (nullableType is null || !nullableType.IsEnum) - { - // Type isn't nullable or isn't an enum. - return null; - } - - var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>(); - return new NullableEnumModelBinder(logger); + // Type isn't nullable or isn't an enum. + return null; } + + var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>(); + return new NullableEnumModelBinder(logger); } } diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs index 4257ba0e23..cb9a829557 100644 --- a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs @@ -5,86 +5,85 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Comma delimited array model binder. +/// Returns an empty array of specified type if there is no query parameter. +/// </summary> +public class PipeDelimitedArrayModelBinder : IModelBinder { + private readonly ILogger<PipeDelimitedArrayModelBinder> _logger; + /// <summary> - /// Comma delimited array model binder. - /// Returns an empty array of specified type if there is no query parameter. + /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class. /// </summary> - public class PipeDelimitedArrayModelBinder : IModelBinder + /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param> + public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + + /// <inheritdoc/> + public Task BindModelAsync(ModelBindingContext bindingContext) { - private readonly ILogger<PipeDelimitedArrayModelBinder> _logger; + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); - /// <summary> - /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param> - public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger) + if (valueProviderResult.Length > 1) { - _logger = logger; + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); } - - /// <inheritdoc/> - public Task BindModelAsync(ModelBindingContext bindingContext) + else { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; - var converter = TypeDescriptor.GetConverter(elementType); + var value = valueProviderResult.FirstValue; - if (valueProviderResult.Length > 1) + if (value is not null) { - var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); bindingContext.Result = ModelBindingResult.Success(typedValues); } else { - var value = valueProviderResult.FirstValue; - - if (value is not null) - { - var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries); - var typedValues = GetParsedResult(splitValues, elementType, converter); - bindingContext.Result = ModelBindingResult.Success(typedValues); - } - else - { - var emptyResult = Array.CreateInstance(elementType, 0); - bindingContext.Result = ModelBindingResult.Success(emptyResult); - } + var emptyResult = Array.CreateInstance(elementType, 0); + bindingContext.Result = ModelBindingResult.Success(emptyResult); } - - return Task.CompletedTask; } - private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + return Task.CompletedTask; + } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) { - var parsedValues = new object?[values.Count]; - var convertedCount = 0; - for (var i = 0; i < values.Count; i++) + try { - try - { - parsedValues[i] = converter.ConvertFromString(values[i].Trim()); - convertedCount++; - } - catch (FormatException e) - { - _logger.LogDebug(e, "Error converting value."); - } + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; } - - var typedValues = Array.CreateInstance(elementType, convertedCount); - var typedValueIndex = 0; - for (var i = 0; i < parsedValues.Length; i++) + catch (FormatException e) { - if (parsedValues[i] != null) - { - typedValues.SetValue(parsedValues[i], typedValueIndex); - typedValueIndex++; - } + _logger.LogDebug(e, "Error converting value."); } + } - return typedValues; + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } } + + return typedValues; } } diff --git a/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs index 44509a9c04..168247fd5b 100644 --- a/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs +++ b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs @@ -1,22 +1,21 @@ -namespace Jellyfin.Api.Models.ClientLogDtos +namespace Jellyfin.Api.Models.ClientLogDtos; + +/// <summary> +/// Client log document response dto. +/// </summary> +public class ClientLogDocumentResponseDto { /// <summary> - /// Client log document response dto. + /// Initializes a new instance of the <see cref="ClientLogDocumentResponseDto"/> class. /// </summary> - public class ClientLogDocumentResponseDto + /// <param name="fileName">The file name.</param> + public ClientLogDocumentResponseDto(string fileName) { - /// <summary> - /// Initializes a new instance of the <see cref="ClientLogDocumentResponseDto"/> class. - /// </summary> - /// <param name="fileName">The file name.</param> - public ClientLogDocumentResponseDto(string fileName) - { - FileName = fileName; - } - - /// <summary> - /// Gets the resulting filename. - /// </summary> - public string FileName { get; } + FileName = fileName; } + + /// <summary> + /// Gets the resulting filename. + /// </summary> + public string FileName { get; } } diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs index 3b827ec12d..5a48345eb6 100644 --- a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs +++ b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.ConfigurationDtos +namespace Jellyfin.Api.Models.ConfigurationDtos; + +/// <summary> +/// Media Encoder Path Dto. +/// </summary> +public class MediaEncoderPathDto { /// <summary> - /// Media Encoder Path Dto. + /// Gets or sets media encoder path. /// </summary> - public class MediaEncoderPathDto - { - /// <summary> - /// Gets or sets media encoder path. - /// </summary> - public string Path { get; set; } = null!; + public string Path { get; set; } = null!; - /// <summary> - /// Gets or sets media encoder path type. - /// </summary> - public string PathType { get; set; } = null!; - } + /// <summary> + /// Gets or sets media encoder path type. + /// </summary> + public string PathType { get; set; } = null!; } diff --git a/Jellyfin.Api/Models/ConfigurationPageInfo.cs b/Jellyfin.Api/Models/ConfigurationPageInfo.cs index ec4a0d1a1c..e7bcd6c533 100644 --- a/Jellyfin.Api/Models/ConfigurationPageInfo.cs +++ b/Jellyfin.Api/Models/ConfigurationPageInfo.cs @@ -2,66 +2,65 @@ using System; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; -namespace Jellyfin.Api.Models +namespace Jellyfin.Api.Models; + +/// <summary> +/// The configuration page info. +/// </summary> +public class ConfigurationPageInfo { /// <summary> - /// The configuration page info. + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. /// </summary> - public class ConfigurationPageInfo + /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> + /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param> + public ConfigurationPageInfo(IPlugin? plugin, PluginPageInfo page) { - /// <summary> - /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. - /// </summary> - /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> - /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param> - public ConfigurationPageInfo(IPlugin? plugin, PluginPageInfo page) - { - Name = page.Name; - EnableInMainMenu = page.EnableInMainMenu; - MenuSection = page.MenuSection; - MenuIcon = page.MenuIcon; - DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name : page.DisplayName; - PluginId = plugin?.Id; - } + Name = page.Name; + EnableInMainMenu = page.EnableInMainMenu; + MenuSection = page.MenuSection; + MenuIcon = page.MenuIcon; + DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name : page.DisplayName; + PluginId = plugin?.Id; + } - /// <summary> - /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. - /// </summary> - public ConfigurationPageInfo() - { - Name = string.Empty; - } + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. + /// </summary> + public ConfigurationPageInfo() + { + Name = string.Empty; + } - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string Name { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the configurations page is enabled in the main menu. - /// </summary> - public bool EnableInMainMenu { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the configurations page is enabled in the main menu. + /// </summary> + public bool EnableInMainMenu { get; set; } - /// <summary> - /// Gets or sets the menu section. - /// </summary> - public string? MenuSection { get; set; } + /// <summary> + /// Gets or sets the menu section. + /// </summary> + public string? MenuSection { get; set; } - /// <summary> - /// Gets or sets the menu icon. - /// </summary> - public string? MenuIcon { get; set; } + /// <summary> + /// Gets or sets the menu icon. + /// </summary> + public string? MenuIcon { get; set; } - /// <summary> - /// Gets or sets the display name. - /// </summary> - public string? DisplayName { get; set; } + /// <summary> + /// Gets or sets the display name. + /// </summary> + public string? DisplayName { get; set; } - /// <summary> - /// Gets or sets the plugin id. - /// </summary> - /// <value>The plugin id.</value> - public Guid? PluginId { get; set; } - } + /// <summary> + /// Gets or sets the plugin id. + /// </summary> + /// <value>The plugin id.</value> + public Guid? PluginId { get; set; } } diff --git a/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs b/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs index 92be15b8a6..c438e5a970 100644 --- a/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs +++ b/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Models.EnvironmentDtos +namespace Jellyfin.Api.Models.EnvironmentDtos; + +/// <summary> +/// Default directory browser info. +/// </summary> +public class DefaultDirectoryBrowserInfoDto { /// <summary> - /// Default directory browser info. + /// Gets or sets the path. /// </summary> - public class DefaultDirectoryBrowserInfoDto - { - /// <summary> - /// Gets or sets the path. - /// </summary> - public string? Path { get; set; } - } + public string? Path { get; set; } } diff --git a/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs b/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs index 418c11c2d0..c54205bfaf 100644 --- a/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs +++ b/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Models.EnvironmentDtos +namespace Jellyfin.Api.Models.EnvironmentDtos; + +/// <summary> +/// Validate path object. +/// </summary> +public class ValidatePathDto { /// <summary> - /// Validate path object. + /// Gets or sets a value indicating whether validate if path is writable. /// </summary> - public class ValidatePathDto - { - /// <summary> - /// Gets or sets a value indicating whether validate if path is writable. - /// </summary> - public bool ValidateWritable { get; set; } + public bool ValidateWritable { get; set; } - /// <summary> - /// Gets or sets the path. - /// </summary> - public string? Path { get; set; } + /// <summary> + /// Gets or sets the path. + /// </summary> + public string? Path { get; set; } - /// <summary> - /// Gets or sets is path file. - /// </summary> - public bool? IsFile { get; set; } - } + /// <summary> + /// Gets or sets is path file. + /// </summary> + public bool? IsFile { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs index 3584344344..6401522f60 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Library option info dto. +/// </summary> +public class LibraryOptionInfoDto { /// <summary> - /// Library option info dto. + /// Gets or sets name. /// </summary> - public class LibraryOptionInfoDto - { - /// <summary> - /// Gets or sets name. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets a value indicating whether default enabled. - /// </summary> - public bool DefaultEnabled { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether default enabled. + /// </summary> + public bool DefaultEnabled { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs index 7de44aa659..78efacd94f 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs @@ -1,31 +1,30 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Library options result dto. +/// </summary> +public class LibraryOptionsResultDto { /// <summary> - /// Library options result dto. + /// Gets or sets the metadata savers. /// </summary> - public class LibraryOptionsResultDto - { - /// <summary> - /// Gets or sets the metadata savers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataSavers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> MetadataSavers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the metadata readers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataReaders { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the metadata readers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> MetadataReaders { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the subtitle fetchers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> SubtitleFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the subtitle fetchers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> SubtitleFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the type options. - /// </summary> - public IReadOnlyList<LibraryTypeOptionsDto> TypeOptions { get; set; } = Array.Empty<LibraryTypeOptionsDto>(); - } + /// <summary> + /// Gets or sets the type options. + /// </summary> + public IReadOnlyList<LibraryTypeOptionsDto> TypeOptions { get; set; } = Array.Empty<LibraryTypeOptionsDto>(); } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs index 20f45196d2..125a6746e8 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -3,36 +3,35 @@ using System.Collections.Generic; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Library type options dto. +/// </summary> +public class LibraryTypeOptionsDto { /// <summary> - /// Library type options dto. + /// Gets or sets the type. /// </summary> - public class LibraryTypeOptionsDto - { - /// <summary> - /// Gets or sets the type. - /// </summary> - public string? Type { get; set; } + public string? Type { get; set; } - /// <summary> - /// Gets or sets the metadata fetchers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the metadata fetchers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the image fetchers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the image fetchers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the supported image types. - /// </summary> - public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); + /// <summary> + /// Gets or sets the supported image types. + /// </summary> + public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); - /// <summary> - /// Gets or sets the default image options. - /// </summary> - public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); - } + /// <summary> + /// Gets or sets the default image options. + /// </summary> + public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); } diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs index f936388980..b34e0bba5d 100644 --- a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs @@ -1,16 +1,15 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Media Update Info Dto. +/// </summary> +public class MediaUpdateInfoDto { /// <summary> - /// Media Update Info Dto. + /// Gets or sets the list of updates. /// </summary> - public class MediaUpdateInfoDto - { - /// <summary> - /// Gets or sets the list of updates. - /// </summary> - public IReadOnlyList<MediaUpdateInfoPathDto> Updates { get; set; } = Array.Empty<MediaUpdateInfoPathDto>(); - } + public IReadOnlyList<MediaUpdateInfoPathDto> Updates { get; set; } = Array.Empty<MediaUpdateInfoPathDto>(); } diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs index 852315b92d..5bbaea669f 100644 --- a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs @@ -1,19 +1,18 @@ -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// The media update info path. +/// </summary> +public class MediaUpdateInfoPathDto { /// <summary> - /// The media update info path. + /// Gets or sets media path. /// </summary> - public class MediaUpdateInfoPathDto - { - /// <summary> - /// Gets or sets media path. - /// </summary> - public string? Path { get; set; } + public string? Path { get; set; } - /// <summary> - /// Gets or sets media update type. - /// Created, Modified, Deleted. - /// </summary> - public string? UpdateType { get; set; } - } + /// <summary> + /// Gets or sets media update type. + /// Created, Modified, Deleted. + /// </summary> + public string? UpdateType { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs index ab68d52238..16d3f65c99 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs @@ -1,15 +1,14 @@ using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Add virtual folder dto. +/// </summary> +public class AddVirtualFolderDto { /// <summary> - /// Add virtual folder dto. + /// Gets or sets library options. /// </summary> - public class AddVirtualFolderDto - { - /// <summary> - /// Gets or sets library options. - /// </summary> - public LibraryOptions? LibraryOptions { get; set; } - } + public LibraryOptions? LibraryOptions { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs index 8b26ec317a..94ffc5238f 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs @@ -1,27 +1,26 @@ using System.ComponentModel.DataAnnotations; using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Media Path dto. +/// </summary> +public class MediaPathDto { /// <summary> - /// Media Path dto. + /// Gets or sets the name of the library. /// </summary> - public class MediaPathDto - { - /// <summary> - /// Gets or sets the name of the library. - /// </summary> - [Required] - public string? Name { get; set; } + [Required] + public string? Name { get; set; } - /// <summary> - /// Gets or sets the path to add. - /// </summary> - public string? Path { get; set; } + /// <summary> + /// Gets or sets the path to add. + /// </summary> + public string? Path { get; set; } - /// <summary> - /// Gets or sets the path info. - /// </summary> - public MediaPathInfo? PathInfo { get; set; } - } + /// <summary> + /// Gets or sets the path info. + /// </summary> + public MediaPathInfo? PathInfo { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs index c78ed51f78..225c7c7bc2 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs @@ -1,21 +1,20 @@ using System; using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Update library options dto. +/// </summary> +public class UpdateLibraryOptionsDto { /// <summary> - /// Update library options dto. + /// Gets or sets the library item id. /// </summary> - public class UpdateLibraryOptionsDto - { - /// <summary> - /// Gets or sets the library item id. - /// </summary> - public Guid Id { get; set; } + public Guid Id { get; set; } - /// <summary> - /// Gets or sets library options. - /// </summary> - public LibraryOptions? LibraryOptions { get; set; } - } + /// <summary> + /// Gets or sets library options. + /// </summary> + public LibraryOptions? LibraryOptions { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs index fbd4985f9c..a4d33f3b9c 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs @@ -1,23 +1,22 @@ using System.ComponentModel.DataAnnotations; using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Update library options dto. +/// </summary> +public class UpdateMediaPathRequestDto { /// <summary> - /// Update library options dto. + /// Gets or sets the library name. /// </summary> - public class UpdateMediaPathRequestDto - { - /// <summary> - /// Gets or sets the library name. - /// </summary> - [Required] - public string Name { get; set; } = null!; + [Required] + public string Name { get; set; } = null!; - /// <summary> - /// Gets or sets library folder path information. - /// </summary> - [Required] - public MediaPathInfo PathInfo { get; set; } = null!; - } + /// <summary> + /// Gets or sets library folder path information. + /// </summary> + [Required] + public MediaPathInfo PathInfo { get; set; } = null!; } diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs index e293c461cf..cbc3548b10 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs @@ -1,34 +1,32 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; -namespace Jellyfin.Api.Models.LiveTvDtos +namespace Jellyfin.Api.Models.LiveTvDtos; + +/// <summary> +/// Channel mapping options dto. +/// </summary> +public class ChannelMappingOptionsDto { /// <summary> - /// Channel mapping options dto. + /// Gets or sets list of tuner channels. /// </summary> - public class ChannelMappingOptionsDto - { - /// <summary> - /// Gets or sets list of tuner channels. - /// </summary> - required public IReadOnlyList<TunerChannelMapping> TunerChannels { get; set; } + public required IReadOnlyList<TunerChannelMapping> TunerChannels { get; set; } - /// <summary> - /// Gets or sets list of provider channels. - /// </summary> - required public IReadOnlyList<NameIdPair> ProviderChannels { get; set; } + /// <summary> + /// Gets or sets list of provider channels. + /// </summary> + public required IReadOnlyList<NameIdPair> ProviderChannels { get; set; } - /// <summary> - /// Gets or sets list of mappings. - /// </summary> - public IReadOnlyList<NameValuePair> Mappings { get; set; } = Array.Empty<NameValuePair>(); + /// <summary> + /// Gets or sets list of mappings. + /// </summary> + public IReadOnlyList<NameValuePair> Mappings { get; set; } = Array.Empty<NameValuePair>(); - /// <summary> - /// Gets or sets provider name. - /// </summary> - public string? ProviderName { get; set; } - } + /// <summary> + /// Gets or sets provider name. + /// </summary> + public string? ProviderName { get; set; } } diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index 411e4c550c..5e7dd689e8 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -6,174 +6,173 @@ using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; -namespace Jellyfin.Api.Models.LiveTvDtos +namespace Jellyfin.Api.Models.LiveTvDtos; + +/// <summary> +/// Get programs dto. +/// </summary> +public class GetProgramsDto { /// <summary> - /// Get programs dto. - /// </summary> - public class GetProgramsDto - { - /// <summary> - /// Gets or sets the channels to return guide information for. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>(); - - /// <summary> - /// Gets or sets optional. Filter by user id. - /// </summary> - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the minimum premiere start date. - /// Optional. - /// </summary> - public DateTime? MinStartDate { get; set; } - - /// <summary> - /// Gets or sets filter by programs that have completed airing, or not. - /// Optional. - /// </summary> - public bool? HasAired { get; set; } - - /// <summary> - /// Gets or sets filter by programs that are currently airing, or not. - /// Optional. - /// </summary> - public bool? IsAiring { get; set; } - - /// <summary> - /// Gets or sets the maximum premiere start date. - /// Optional. - /// </summary> - public DateTime? MaxStartDate { get; set; } - - /// <summary> - /// Gets or sets the minimum premiere end date. - /// Optional. - /// </summary> - public DateTime? MinEndDate { get; set; } - - /// <summary> - /// Gets or sets the maximum premiere end date. - /// Optional. - /// </summary> - public DateTime? MaxEndDate { get; set; } - - /// <summary> - /// Gets or sets filter for movies. - /// Optional. - /// </summary> - public bool? IsMovie { get; set; } - - /// <summary> - /// Gets or sets filter for series. - /// Optional. - /// </summary> - public bool? IsSeries { get; set; } - - /// <summary> - /// Gets or sets filter for news. - /// Optional. - /// </summary> - public bool? IsNews { get; set; } - - /// <summary> - /// Gets or sets filter for kids. - /// Optional. - /// </summary> - public bool? IsKids { get; set; } - - /// <summary> - /// Gets or sets filter for sports. - /// Optional. - /// </summary> - public bool? IsSports { get; set; } - - /// <summary> - /// Gets or sets the record index to start at. All items with a lower index will be dropped from the results. - /// Optional. - /// </summary> - public int? StartIndex { get; set; } - - /// <summary> - /// Gets or sets the maximum number of records to return. - /// Optional. - /// </summary> - public int? Limit { get; set; } - - /// <summary> - /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate. - /// Optional. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<string> SortBy { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets sort Order - Ascending,Descending. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<SortOrder> SortOrder { get; set; } = Array.Empty<SortOrder>(); - - /// <summary> - /// Gets or sets the genres to return guide information for. - /// </summary> - [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))] - public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets the genre ids to return guide information for. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>(); - - /// <summary> - /// Gets or sets include image information in output. - /// Optional. - /// </summary> - public bool? EnableImages { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether retrieve total record count. - /// </summary> - public bool EnableTotalRecordCount { get; set; } = true; - - /// <summary> - /// Gets or sets the max number of images to return, per image type. - /// Optional. - /// </summary> - public int? ImageTypeLimit { get; set; } - - /// <summary> - /// Gets or sets the image types to include in the output. - /// Optional. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<ImageType> EnableImageTypes { get; set; } = Array.Empty<ImageType>(); - - /// <summary> - /// Gets or sets include user data. - /// Optional. - /// </summary> - public bool? EnableUserData { get; set; } - - /// <summary> - /// Gets or sets filter by series timer id. - /// Optional. - /// </summary> - public string? SeriesTimerId { get; set; } - - /// <summary> - /// Gets or sets filter by library series id. - /// Optional. - /// </summary> - public Guid LibrarySeriesId { get; set; } - - /// <summary> - /// Gets or sets 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. - /// Optional. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<ItemFields> Fields { get; set; } = Array.Empty<ItemFields>(); - } + /// Gets or sets the channels to return guide information for. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>(); + + /// <summary> + /// Gets or sets optional. Filter by user id. + /// </summary> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the minimum premiere start date. + /// Optional. + /// </summary> + public DateTime? MinStartDate { get; set; } + + /// <summary> + /// Gets or sets filter by programs that have completed airing, or not. + /// Optional. + /// </summary> + public bool? HasAired { get; set; } + + /// <summary> + /// Gets or sets filter by programs that are currently airing, or not. + /// Optional. + /// </summary> + public bool? IsAiring { get; set; } + + /// <summary> + /// Gets or sets the maximum premiere start date. + /// Optional. + /// </summary> + public DateTime? MaxStartDate { get; set; } + + /// <summary> + /// Gets or sets the minimum premiere end date. + /// Optional. + /// </summary> + public DateTime? MinEndDate { get; set; } + + /// <summary> + /// Gets or sets the maximum premiere end date. + /// Optional. + /// </summary> + public DateTime? MaxEndDate { get; set; } + + /// <summary> + /// Gets or sets filter for movies. + /// Optional. + /// </summary> + public bool? IsMovie { get; set; } + + /// <summary> + /// Gets or sets filter for series. + /// Optional. + /// </summary> + public bool? IsSeries { get; set; } + + /// <summary> + /// Gets or sets filter for news. + /// Optional. + /// </summary> + public bool? IsNews { get; set; } + + /// <summary> + /// Gets or sets filter for kids. + /// Optional. + /// </summary> + public bool? IsKids { get; set; } + + /// <summary> + /// Gets or sets filter for sports. + /// Optional. + /// </summary> + public bool? IsSports { get; set; } + + /// <summary> + /// Gets or sets the record index to start at. All items with a lower index will be dropped from the results. + /// Optional. + /// </summary> + public int? StartIndex { get; set; } + + /// <summary> + /// Gets or sets the maximum number of records to return. + /// Optional. + /// </summary> + public int? Limit { get; set; } + + /// <summary> + /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate. + /// Optional. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<string> SortBy { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets sort Order - Ascending,Descending. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<SortOrder> SortOrder { get; set; } = Array.Empty<SortOrder>(); + + /// <summary> + /// Gets or sets the genres to return guide information for. + /// </summary> + [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))] + public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the genre ids to return guide information for. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>(); + + /// <summary> + /// Gets or sets include image information in output. + /// Optional. + /// </summary> + public bool? EnableImages { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether retrieve total record count. + /// </summary> + public bool EnableTotalRecordCount { get; set; } = true; + + /// <summary> + /// Gets or sets the max number of images to return, per image type. + /// Optional. + /// </summary> + public int? ImageTypeLimit { get; set; } + + /// <summary> + /// Gets or sets the image types to include in the output. + /// Optional. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<ImageType> EnableImageTypes { get; set; } = Array.Empty<ImageType>(); + + /// <summary> + /// Gets or sets include user data. + /// Optional. + /// </summary> + public bool? EnableUserData { get; set; } + + /// <summary> + /// Gets or sets filter by series timer id. + /// Optional. + /// </summary> + public string? SeriesTimerId { get; set; } + + /// <summary> + /// Gets or sets filter by library series id. + /// Optional. + /// </summary> + public Guid LibrarySeriesId { get; set; } + + /// <summary> + /// Gets or sets 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. + /// Optional. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<ItemFields> Fields { get; set; } = Array.Empty<ItemFields>(); } diff --git a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs index e7501bd9fb..2dbaece5e1 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs @@ -1,28 +1,27 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.LiveTvDtos +namespace Jellyfin.Api.Models.LiveTvDtos; + +/// <summary> +/// Set channel mapping dto. +/// </summary> +public class SetChannelMappingDto { /// <summary> - /// Set channel mapping dto. + /// Gets or sets the provider id. /// </summary> - public class SetChannelMappingDto - { - /// <summary> - /// Gets or sets the provider id. - /// </summary> - [Required] - public string ProviderId { get; set; } = string.Empty; + [Required] + public string ProviderId { get; set; } = string.Empty; - /// <summary> - /// Gets or sets the tuner channel id. - /// </summary> - [Required] - public string TunerChannelId { get; set; } = string.Empty; + /// <summary> + /// Gets or sets the tuner channel id. + /// </summary> + [Required] + public string TunerChannelId { get; set; } = string.Empty; - /// <summary> - /// Gets or sets the provider channel id. - /// </summary> - [Required] - public string ProviderChannelId { get; set; } = string.Empty; - } + /// <summary> + /// Gets or sets the provider channel id. + /// </summary> + [Required] + public string ProviderChannelId { get; set; } = string.Empty; } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs index 7045423261..99b3f70207 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -3,76 +3,75 @@ using System.Collections.Generic; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; -namespace Jellyfin.Api.Models.MediaInfoDtos +namespace Jellyfin.Api.Models.MediaInfoDtos; + +/// <summary> +/// Open live stream dto. +/// </summary> +public class OpenLiveStreamDto { /// <summary> - /// Open live stream dto. + /// Gets or sets the open token. /// </summary> - public class OpenLiveStreamDto - { - /// <summary> - /// Gets or sets the open token. - /// </summary> - public string? OpenToken { get; set; } + public string? OpenToken { get; set; } - /// <summary> - /// Gets or sets the user id. - /// </summary> - public Guid? UserId { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid? UserId { get; set; } - /// <summary> - /// Gets or sets the play session id. - /// </summary> - public string? PlaySessionId { get; set; } + /// <summary> + /// Gets or sets the play session id. + /// </summary> + public string? PlaySessionId { get; set; } - /// <summary> - /// Gets or sets the max streaming bitrate. - /// </summary> - public int? MaxStreamingBitrate { get; set; } + /// <summary> + /// Gets or sets the max streaming bitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } - /// <summary> - /// Gets or sets the start time in ticks. - /// </summary> - public long? StartTimeTicks { get; set; } + /// <summary> + /// Gets or sets the start time in ticks. + /// </summary> + public long? StartTimeTicks { get; set; } - /// <summary> - /// Gets or sets the audio stream index. - /// </summary> - public int? AudioStreamIndex { get; set; } + /// <summary> + /// Gets or sets the audio stream index. + /// </summary> + public int? AudioStreamIndex { get; set; } - /// <summary> - /// Gets or sets the subtitle stream index. - /// </summary> - public int? SubtitleStreamIndex { get; set; } + /// <summary> + /// Gets or sets the subtitle stream index. + /// </summary> + public int? SubtitleStreamIndex { get; set; } - /// <summary> - /// Gets or sets the max audio channels. - /// </summary> - public int? MaxAudioChannels { get; set; } + /// <summary> + /// Gets or sets the max audio channels. + /// </summary> + public int? MaxAudioChannels { get; set; } - /// <summary> - /// Gets or sets the item id. - /// </summary> - public Guid? ItemId { get; set; } + /// <summary> + /// Gets or sets the item id. + /// </summary> + public Guid? ItemId { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to enable direct play. - /// </summary> - public bool? EnableDirectPlay { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to enable direct play. + /// </summary> + public bool? EnableDirectPlay { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to enale direct stream. - /// </summary> - public bool? EnableDirectStream { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to enale direct stream. + /// </summary> + public bool? EnableDirectStream { get; set; } - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } - /// <summary> - /// Gets or sets the device play protocols. - /// </summary> - public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } = Array.Empty<MediaProtocol>(); - } + /// <summary> + /// Gets or sets the device play protocols. + /// </summary> + public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } = Array.Empty<MediaProtocol>(); } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs index c6bd5e56ec..0ef1867cd1 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs @@ -1,86 +1,85 @@ using System; using MediaBrowser.Model.Dlna; -namespace Jellyfin.Api.Models.MediaInfoDtos +namespace Jellyfin.Api.Models.MediaInfoDtos; + +/// <summary> +/// Plabyback info dto. +/// </summary> +public class PlaybackInfoDto { /// <summary> - /// Plabyback info dto. + /// Gets or sets the playback userId. + /// </summary> + public Guid? UserId { get; set; } + + /// <summary> + /// Gets or sets the max streaming bitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } + + /// <summary> + /// Gets or sets the start time in ticks. + /// </summary> + public long? StartTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the audio stream index. + /// </summary> + public int? AudioStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the subtitle stream index. + /// </summary> + public int? SubtitleStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the max audio channels. + /// </summary> + public int? MaxAudioChannels { get; set; } + + /// <summary> + /// Gets or sets the media source id. + /// </summary> + public string? MediaSourceId { get; set; } + + /// <summary> + /// Gets or sets the live stream id. + /// </summary> + public string? LiveStreamId { get; set; } + + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct play. + /// </summary> + public bool? EnableDirectPlay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct stream. + /// </summary> + public bool? EnableDirectStream { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable transcoding. + /// </summary> + public bool? EnableTranscoding { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable video stream copy. + /// </summary> + public bool? AllowVideoStreamCopy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to allow audio stream copy. + /// </summary> + public bool? AllowAudioStreamCopy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to auto open the live stream. /// </summary> - public class PlaybackInfoDto - { - /// <summary> - /// Gets or sets the playback userId. - /// </summary> - public Guid? UserId { get; set; } - - /// <summary> - /// Gets or sets the max streaming bitrate. - /// </summary> - public int? MaxStreamingBitrate { get; set; } - - /// <summary> - /// Gets or sets the start time in ticks. - /// </summary> - public long? StartTimeTicks { get; set; } - - /// <summary> - /// Gets or sets the audio stream index. - /// </summary> - public int? AudioStreamIndex { get; set; } - - /// <summary> - /// Gets or sets the subtitle stream index. - /// </summary> - public int? SubtitleStreamIndex { get; set; } - - /// <summary> - /// Gets or sets the max audio channels. - /// </summary> - public int? MaxAudioChannels { get; set; } - - /// <summary> - /// Gets or sets the media source id. - /// </summary> - public string? MediaSourceId { get; set; } - - /// <summary> - /// Gets or sets the live stream id. - /// </summary> - public string? LiveStreamId { get; set; } - - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable direct play. - /// </summary> - public bool? EnableDirectPlay { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable direct stream. - /// </summary> - public bool? EnableDirectStream { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable transcoding. - /// </summary> - public bool? EnableTranscoding { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable video stream copy. - /// </summary> - public bool? AllowVideoStreamCopy { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to allow audio stream copy. - /// </summary> - public bool? AllowAudioStreamCopy { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to auto open the live stream. - /// </summary> - public bool? AutoOpenLiveStream { get; set; } - } + public bool? AutoOpenLiveStream { get; set; } } diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs index 9060500c8b..480ddab098 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs @@ -6,279 +6,278 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Models.PlaybackDtos +namespace Jellyfin.Api.Models.PlaybackDtos; + +/// <summary> +/// Class TranscodingJob. +/// </summary> +public class TranscodingJobDto : IDisposable { /// <summary> - /// Class TranscodingJob. + /// The process lock. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + public readonly object ProcessLock = new object(); + + /// <summary> + /// Timer lock. /// </summary> - public class TranscodingJobDto : IDisposable + private readonly object _timerLock = new object(); + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingJobDto"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param> + public TranscodingJobDto(ILogger<TranscodingJobDto> logger) { - /// <summary> - /// The process lock. - /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - public readonly object ProcessLock = new object(); - - /// <summary> - /// Timer lock. - /// </summary> - private readonly object _timerLock = new object(); - - /// <summary> - /// Initializes a new instance of the <see cref="TranscodingJobDto"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param> - public TranscodingJobDto(ILogger<TranscodingJobDto> logger) - { - Logger = logger; - } + Logger = logger; + } + + /// <summary> + /// Gets or sets the play session identifier. + /// </summary> + /// <value>The play session identifier.</value> + public string? PlaySessionId { get; set; } + + /// <summary> + /// Gets or sets the live stream identifier. + /// </summary> + /// <value>The live stream identifier.</value> + public string? LiveStreamId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is live output. + /// </summary> + public bool IsLiveOutput { get; set; } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public MediaSourceInfo? MediaSource { get; set; } + + /// <summary> + /// Gets or sets path. + /// </summary> + public string? Path { get; set; } + + /// <summary> + /// Gets or sets the type. + /// </summary> + /// <value>The type.</value> + public TranscodingJobType Type { get; set; } + + /// <summary> + /// Gets or sets the process. + /// </summary> + /// <value>The process.</value> + public Process? Process { get; set; } + + /// <summary> + /// Gets logger. + /// </summary> + public ILogger<TranscodingJobDto> Logger { get; private set; } + + /// <summary> + /// Gets or sets the active request count. + /// </summary> + /// <value>The active request count.</value> + public int ActiveRequestCount { get; set; } + + /// <summary> + /// Gets or sets the kill timer. + /// </summary> + /// <value>The kill timer.</value> + private Timer? KillTimer { get; set; } + + /// <summary> + /// Gets or sets device id. + /// </summary> + public string? DeviceId { get; set; } + + /// <summary> + /// Gets or sets cancellation token source. + /// </summary> + public CancellationTokenSource? CancellationTokenSource { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether has exited. + /// </summary> + public bool HasExited { get; set; } + + /// <summary> + /// Gets or sets exit code. + /// </summary> + public int ExitCode { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is user paused. + /// </summary> + public bool IsUserPaused { get; set; } + + /// <summary> + /// Gets or sets id. + /// </summary> + public string? Id { get; set; } + + /// <summary> + /// Gets or sets framerate. + /// </summary> + public float? Framerate { get; set; } + + /// <summary> + /// Gets or sets completion percentage. + /// </summary> + public double? CompletionPercentage { get; set; } - /// <summary> - /// Gets or sets the play session identifier. - /// </summary> - /// <value>The play session identifier.</value> - public string? PlaySessionId { get; set; } - - /// <summary> - /// Gets or sets the live stream identifier. - /// </summary> - /// <value>The live stream identifier.</value> - public string? LiveStreamId { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether is live output. - /// </summary> - public bool IsLiveOutput { get; set; } - - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - public MediaSourceInfo? MediaSource { get; set; } - - /// <summary> - /// Gets or sets path. - /// </summary> - public string? Path { get; set; } - - /// <summary> - /// Gets or sets the type. - /// </summary> - /// <value>The type.</value> - public TranscodingJobType Type { get; set; } - - /// <summary> - /// Gets or sets the process. - /// </summary> - /// <value>The process.</value> - public Process? Process { get; set; } - - /// <summary> - /// Gets logger. - /// </summary> - public ILogger<TranscodingJobDto> Logger { get; private set; } - - /// <summary> - /// Gets or sets the active request count. - /// </summary> - /// <value>The active request count.</value> - public int ActiveRequestCount { get; set; } - - /// <summary> - /// Gets or sets the kill timer. - /// </summary> - /// <value>The kill timer.</value> - private Timer? KillTimer { get; set; } - - /// <summary> - /// Gets or sets device id. - /// </summary> - public string? DeviceId { get; set; } - - /// <summary> - /// Gets or sets cancellation token source. - /// </summary> - public CancellationTokenSource? CancellationTokenSource { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether has exited. - /// </summary> - public bool HasExited { get; set; } - - /// <summary> - /// Gets or sets exit code. - /// </summary> - public int ExitCode { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether is user paused. - /// </summary> - public bool IsUserPaused { get; set; } - - /// <summary> - /// Gets or sets id. - /// </summary> - public string? Id { get; set; } - - /// <summary> - /// Gets or sets framerate. - /// </summary> - public float? Framerate { get; set; } - - /// <summary> - /// Gets or sets completion percentage. - /// </summary> - public double? CompletionPercentage { get; set; } - - /// <summary> - /// Gets or sets bytes downloaded. - /// </summary> - public long BytesDownloaded { get; set; } - - /// <summary> - /// Gets or sets bytes transcoded. - /// </summary> - public long? BytesTranscoded { get; set; } - - /// <summary> - /// Gets or sets bit rate. - /// </summary> - public int? BitRate { get; set; } - - /// <summary> - /// Gets or sets transcoding position ticks. - /// </summary> - public long? TranscodingPositionTicks { get; set; } - - /// <summary> - /// Gets or sets download position ticks. - /// </summary> - public long? DownloadPositionTicks { get; set; } - - /// <summary> - /// Gets or sets transcoding throttler. - /// </summary> - public TranscodingThrottler? TranscodingThrottler { get; set; } - - /// <summary> - /// Gets or sets last ping date. - /// </summary> - public DateTime LastPingDate { get; set; } - - /// <summary> - /// Gets or sets ping timeout. - /// </summary> - public int PingTimeout { get; set; } - - /// <summary> - /// Stop kill timer. - /// </summary> - public void StopKillTimer() + /// <summary> + /// Gets or sets bytes downloaded. + /// </summary> + public long BytesDownloaded { get; set; } + + /// <summary> + /// Gets or sets bytes transcoded. + /// </summary> + public long? BytesTranscoded { get; set; } + + /// <summary> + /// Gets or sets bit rate. + /// </summary> + public int? BitRate { get; set; } + + /// <summary> + /// Gets or sets transcoding position ticks. + /// </summary> + public long? TranscodingPositionTicks { get; set; } + + /// <summary> + /// Gets or sets download position ticks. + /// </summary> + public long? DownloadPositionTicks { get; set; } + + /// <summary> + /// Gets or sets transcoding throttler. + /// </summary> + public TranscodingThrottler? TranscodingThrottler { get; set; } + + /// <summary> + /// Gets or sets last ping date. + /// </summary> + public DateTime LastPingDate { get; set; } + + /// <summary> + /// Gets or sets ping timeout. + /// </summary> + public int PingTimeout { get; set; } + + /// <summary> + /// Stop kill timer. + /// </summary> + public void StopKillTimer() + { + lock (_timerLock) { - lock (_timerLock) - { - KillTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } + KillTimer?.Change(Timeout.Infinite, Timeout.Infinite); } + } - /// <summary> - /// Dispose kill timer. - /// </summary> - public void DisposeKillTimer() + /// <summary> + /// Dispose kill timer. + /// </summary> + public void DisposeKillTimer() + { + lock (_timerLock) { - lock (_timerLock) + if (KillTimer is not null) { - if (KillTimer is not null) - { - KillTimer.Dispose(); - KillTimer = null; - } + KillTimer.Dispose(); + KillTimer = null; } } + } + + /// <summary> + /// Start kill timer. + /// </summary> + /// <param name="callback">Callback action.</param> + public void StartKillTimer(Action<object?> callback) + { + StartKillTimer(callback, PingTimeout); + } - /// <summary> - /// Start kill timer. - /// </summary> - /// <param name="callback">Callback action.</param> - public void StartKillTimer(Action<object?> callback) + /// <summary> + /// Start kill timer. + /// </summary> + /// <param name="callback">Callback action.</param> + /// <param name="intervalMs">Callback interval.</param> + public void StartKillTimer(Action<object?> callback, int intervalMs) + { + if (HasExited) { - StartKillTimer(callback, PingTimeout); + return; } - /// <summary> - /// Start kill timer. - /// </summary> - /// <param name="callback">Callback action.</param> - /// <param name="intervalMs">Callback interval.</param> - public void StartKillTimer(Action<object?> callback, int intervalMs) + lock (_timerLock) { - if (HasExited) + if (KillTimer is null) { - return; + Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); } - - lock (_timerLock) + else { - if (KillTimer is null) - { - Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); - } - else - { - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } + Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); } } + } - /// <summary> - /// Change kill timer if started. - /// </summary> - public void ChangeKillTimerIfStarted() + /// <summary> + /// Change kill timer if started. + /// </summary> + public void ChangeKillTimerIfStarted() + { + if (HasExited) { - if (HasExited) - { - return; - } + return; + } - lock (_timerLock) + lock (_timerLock) + { + if (KillTimer is not null) { - if (KillTimer is not null) - { - var intervalMs = PingTimeout; + var intervalMs = PingTimeout; - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } + Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); } } + } - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// <summary> - /// Dispose all resources. - /// </summary> - /// <param name="disposing">Whether to dispose all resources.</param> - protected virtual void Dispose(bool disposing) + /// <summary> + /// Dispose all resources. + /// </summary> + /// <param name="disposing">Whether to dispose all resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { - if (disposing) - { - Process?.Dispose(); - Process = null; - KillTimer?.Dispose(); - KillTimer = null; - CancellationTokenSource?.Dispose(); - CancellationTokenSource = null; - TranscodingThrottler?.Dispose(); - TranscodingThrottler = null; - } + Process?.Dispose(); + Process = null; + KillTimer?.Dispose(); + KillTimer = null; + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + TranscodingThrottler?.Dispose(); + TranscodingThrottler = null; } } } diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs index 9c4e377cde..b577c4ea6a 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs @@ -7,214 +7,213 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Models.PlaybackDtos +namespace Jellyfin.Api.Models.PlaybackDtos; + +/// <summary> +/// Transcoding throttler. +/// </summary> +public class TranscodingThrottler : IDisposable { + private readonly TranscodingJobDto _job; + private readonly ILogger<TranscodingThrottler> _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IMediaEncoder _mediaEncoder; + private Timer? _timer; + private bool _isPaused; + /// <summary> - /// Transcoding throttler. + /// Initializes a new instance of the <see cref="TranscodingThrottler"/> class. /// </summary> - public class TranscodingThrottler : IDisposable + /// <param name="job">Transcoding job dto.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) { - private readonly TranscodingJobDto _job; - private readonly ILogger<TranscodingThrottler> _logger; - private readonly IConfigurationManager _config; - private readonly IFileSystem _fileSystem; - private readonly IMediaEncoder _mediaEncoder; - private Timer? _timer; - private bool _isPaused; - - /// <summary> - /// Initializes a new instance of the <see cref="TranscodingThrottler"/> class. - /// </summary> - /// <param name="job">Transcoding job dto.</param> - /// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param> - /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) - { - _job = job; - _logger = logger; - _config = config; - _fileSystem = fileSystem; - _mediaEncoder = mediaEncoder; - } + _job = job; + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + } - /// <summary> - /// Start timer. - /// </summary> - public void Start() - { - _timer = new Timer(TimerCallback, null, 5000, 5000); - } + /// <summary> + /// Start timer. + /// </summary> + public void Start() + { + _timer = new Timer(TimerCallback, null, 5000, 5000); + } - /// <summary> - /// Unpause transcoding. - /// </summary> - /// <returns>A <see cref="Task"/>.</returns> - public async Task UnpauseTranscoding() + /// <summary> + /// Unpause transcoding. + /// </summary> + /// <returns>A <see cref="Task"/>.</returns> + public async Task UnpauseTranscoding() + { + if (_isPaused) { - if (_isPaused) - { - _logger.LogDebug("Sending resume command to ffmpeg"); + _logger.LogDebug("Sending resume command to ffmpeg"); - try - { - var resumeKey = _mediaEncoder.IsPkeyPauseSupported ? "u" : Environment.NewLine; - await _job.Process!.StandardInput.WriteAsync(resumeKey).ConfigureAwait(false); - _isPaused = false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resuming transcoding"); - } + try + { + var resumeKey = _mediaEncoder.IsPkeyPauseSupported ? "u" : Environment.NewLine; + await _job.Process!.StandardInput.WriteAsync(resumeKey).ConfigureAwait(false); + _isPaused = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resuming transcoding"); } } + } - /// <summary> - /// Stop throttler. - /// </summary> - /// <returns>A <see cref="Task"/>.</returns> - public async Task Stop() + /// <summary> + /// Stop throttler. + /// </summary> + /// <returns>A <see cref="Task"/>.</returns> + public async Task Stop() + { + DisposeTimer(); + await UnpauseTranscoding().ConfigureAwait(false); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + /// <param name="disposing">Disposing.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { DisposeTimer(); - await UnpauseTranscoding().ConfigureAwait(false); } + } - /// <summary> - /// Dispose throttler. - /// </summary> - public void Dispose() + private EncodingOptions GetOptions() + { + return _config.GetEncodingOptions(); + } + + private async void TimerCallback(object? state) + { + if (_job.HasExited) { - Dispose(true); - GC.SuppressFinalize(this); + DisposeTimer(); + return; } - /// <summary> - /// Dispose throttler. - /// </summary> - /// <param name="disposing">Disposing.</param> - protected virtual void Dispose(bool disposing) + var options = GetOptions(); + + if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) { - if (disposing) - { - DisposeTimer(); - } + await PauseTranscoding().ConfigureAwait(false); } - - private EncodingOptions GetOptions() + else { - return _config.GetEncodingOptions(); + await UnpauseTranscoding().ConfigureAwait(false); } + } - private async void TimerCallback(object? state) + private async Task PauseTranscoding() + { + if (!_isPaused) { - if (_job.HasExited) - { - DisposeTimer(); - return; - } + var pauseKey = _mediaEncoder.IsPkeyPauseSupported ? "p" : "c"; - var options = GetOptions(); + _logger.LogDebug("Sending pause command [{Key}] to ffmpeg", pauseKey); - if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) + try { - await PauseTranscoding().ConfigureAwait(false); + await _job.Process!.StandardInput.WriteAsync(pauseKey).ConfigureAwait(false); + _isPaused = true; } - else + catch (Exception ex) { - await UnpauseTranscoding().ConfigureAwait(false); + _logger.LogError(ex, "Error pausing transcoding"); } } + } - private async Task PauseTranscoding() + private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) + { + var bytesDownloaded = job.BytesDownloaded; + var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; + var downloadPositionTicks = job.DownloadPositionTicks ?? 0; + + var path = job.Path ?? throw new ArgumentException("Path can't be null."); + + var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks; + + if (downloadPositionTicks > 0 && transcodingPositionTicks > 0) { - if (!_isPaused) - { - var pauseKey = _mediaEncoder.IsPkeyPauseSupported ? "p" : "c"; + // HLS - time-based consideration - _logger.LogDebug("Sending pause command [{Key}] to ffmpeg", pauseKey); + var targetGap = gapLengthInTicks; + var gap = transcodingPositionTicks - downloadPositionTicks; - try - { - await _job.Process!.StandardInput.WriteAsync(pauseKey).ConfigureAwait(false); - _isPaused = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error pausing transcoding"); - } + if (gap < targetGap) + { + _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap); + return false; } + + _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap); + return true; } - private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) + if (bytesDownloaded > 0 && transcodingPositionTicks > 0) { - var bytesDownloaded = job.BytesDownloaded; - var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; - var downloadPositionTicks = job.DownloadPositionTicks ?? 0; - - var path = job.Path ?? throw new ArgumentException("Path can't be null."); - - var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks; + // Progressive Streaming - byte-based consideration - if (downloadPositionTicks > 0 && transcodingPositionTicks > 0) + try { - // HLS - time-based consideration + var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length; - var targetGap = gapLengthInTicks; - var gap = transcodingPositionTicks - downloadPositionTicks; + // Estimate the bytes the transcoder should be ahead + double gapFactor = gapLengthInTicks; + gapFactor /= transcodingPositionTicks; + var targetGap = bytesTranscoded * gapFactor; + + var gap = bytesTranscoded - bytesDownloaded; if (gap < targetGap) { - _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap); + _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); return false; } - _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap); + _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); return true; } - - if (bytesDownloaded > 0 && transcodingPositionTicks > 0) + catch (Exception ex) { - // Progressive Streaming - byte-based consideration - - try - { - var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length; - - // Estimate the bytes the transcoder should be ahead - double gapFactor = gapLengthInTicks; - gapFactor /= transcodingPositionTicks; - var targetGap = bytesTranscoded * gapFactor; - - var gap = bytesTranscoded - bytesDownloaded; - - if (gap < targetGap) - { - _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); - return false; - } - - _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting output size"); - return false; - } + _logger.LogError(ex, "Error getting output size"); + return false; } - - _logger.LogDebug("No throttle data for {Path}", path); - return false; } - private void DisposeTimer() + _logger.LogDebug("No throttle data for {Path}", path); + return false; + } + + private void DisposeTimer() + { + if (_timer is not null) { - if (_timer is not null) - { - _timer.Dispose(); - _timer = null; - } + _timer.Dispose(); + _timer = null; } } } diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index 0761b20855..1fba32c5b8 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -3,32 +3,31 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Extensions.Json.Converters; -namespace Jellyfin.Api.Models.PlaylistDtos +namespace Jellyfin.Api.Models.PlaylistDtos; + +/// <summary> +/// Create new playlist dto. +/// </summary> +public class CreatePlaylistDto { /// <summary> - /// Create new playlist dto. + /// Gets or sets the name of the new playlist. /// </summary> - public class CreatePlaylistDto - { - /// <summary> - /// Gets or sets the name of the new playlist. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets item ids to add to the playlist. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); + /// <summary> + /// Gets or sets item ids to add to the playlist. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); - /// <summary> - /// Gets or sets the user id. - /// </summary> - public Guid? UserId { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid? UserId { get; set; } - /// <summary> - /// Gets or sets the media type. - /// </summary> - public string? MediaType { get; set; } - } + /// <summary> + /// Gets or sets the media type. + /// </summary> + public string? MediaType { get; set; } } diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs index fa62472e1e..b88be33b22 100644 --- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -5,84 +5,83 @@ using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Session; -namespace Jellyfin.Api.Models.SessionDtos +namespace Jellyfin.Api.Models.SessionDtos; + +/// <summary> +/// Client capabilities dto. +/// </summary> +public class ClientCapabilitiesDto { /// <summary> - /// Client capabilities dto. + /// Gets or sets the list of playable media types. /// </summary> - public class ClientCapabilitiesDto - { - /// <summary> - /// Gets or sets the list of playable media types. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>(); + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>(); - /// <summary> - /// Gets or sets the list of supported commands. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>(); + /// <summary> + /// Gets or sets the list of supported commands. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>(); - /// <summary> - /// Gets or sets a value indicating whether session supports media control. - /// </summary> - public bool SupportsMediaControl { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports media control. + /// </summary> + public bool SupportsMediaControl { get; set; } - /// <summary> - /// Gets or sets a value indicating whether session supports content uploading. - /// </summary> - public bool SupportsContentUploading { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports content uploading. + /// </summary> + public bool SupportsContentUploading { get; set; } - /// <summary> - /// Gets or sets the message callback url. - /// </summary> - public string? MessageCallbackUrl { get; set; } + /// <summary> + /// Gets or sets the message callback url. + /// </summary> + public string? MessageCallbackUrl { get; set; } - /// <summary> - /// Gets or sets a value indicating whether session supports a persistent identifier. - /// </summary> - public bool SupportsPersistentIdentifier { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports a persistent identifier. + /// </summary> + public bool SupportsPersistentIdentifier { get; set; } - /// <summary> - /// Gets or sets a value indicating whether session supports sync. - /// </summary> - public bool SupportsSync { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports sync. + /// </summary> + public bool SupportsSync { get; set; } - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } - /// <summary> - /// Gets or sets the app store url. - /// </summary> - public string? AppStoreUrl { get; set; } + /// <summary> + /// Gets or sets the app store url. + /// </summary> + public string? AppStoreUrl { get; set; } - /// <summary> - /// Gets or sets the icon url. - /// </summary> - public string? IconUrl { get; set; } + /// <summary> + /// Gets or sets the icon url. + /// </summary> + public string? IconUrl { get; set; } - /// <summary> - /// Convert the dto to the full <see cref="ClientCapabilities"/> model. - /// </summary> - /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns> - public ClientCapabilities ToClientCapabilities() + /// <summary> + /// Convert the dto to the full <see cref="ClientCapabilities"/> model. + /// </summary> + /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns> + public ClientCapabilities ToClientCapabilities() + { + return new ClientCapabilities { - return new ClientCapabilities - { - PlayableMediaTypes = PlayableMediaTypes, - SupportedCommands = SupportedCommands, - SupportsMediaControl = SupportsMediaControl, - SupportsContentUploading = SupportsContentUploading, - MessageCallbackUrl = MessageCallbackUrl, - SupportsPersistentIdentifier = SupportsPersistentIdentifier, - SupportsSync = SupportsSync, - DeviceProfile = DeviceProfile, - AppStoreUrl = AppStoreUrl, - IconUrl = IconUrl - }; - } + PlayableMediaTypes = PlayableMediaTypes, + SupportedCommands = SupportedCommands, + SupportsMediaControl = SupportsMediaControl, + SupportsContentUploading = SupportsContentUploading, + MessageCallbackUrl = MessageCallbackUrl, + SupportsPersistentIdentifier = SupportsPersistentIdentifier, + SupportsSync = SupportsSync, + DeviceProfile = DeviceProfile, + AppStoreUrl = AppStoreUrl, + IconUrl = IconUrl + }; } } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs index a5f012245a..4027078190 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Models.StartupDtos +namespace Jellyfin.Api.Models.StartupDtos; + +/// <summary> +/// The startup configuration DTO. +/// </summary> +public class StartupConfigurationDto { /// <summary> - /// The startup configuration DTO. + /// Gets or sets UI language culture. /// </summary> - public class StartupConfigurationDto - { - /// <summary> - /// Gets or sets UI language culture. - /// </summary> - public string? UICulture { get; set; } + public string? UICulture { get; set; } - /// <summary> - /// Gets or sets the metadata country code. - /// </summary> - public string? MetadataCountryCode { get; set; } + /// <summary> + /// Gets or sets the metadata country code. + /// </summary> + public string? MetadataCountryCode { get; set; } - /// <summary> - /// Gets or sets the preferred language for the metadata. - /// </summary> - public string? PreferredMetadataLanguage { get; set; } - } + /// <summary> + /// Gets or sets the preferred language for the metadata. + /// </summary> + public string? PreferredMetadataLanguage { get; set; } } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs index 4027ba41ae..0e7be24c40 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs @@ -1,22 +1,21 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.StartupDtos +namespace Jellyfin.Api.Models.StartupDtos; + +/// <summary> +/// Startup remote access dto. +/// </summary> +public class StartupRemoteAccessDto { /// <summary> - /// Startup remote access dto. + /// Gets or sets a value indicating whether enable remote access. /// </summary> - public class StartupRemoteAccessDto - { - /// <summary> - /// Gets or sets a value indicating whether enable remote access. - /// </summary> - [Required] - public bool EnableRemoteAccess { get; set; } + [Required] + public bool EnableRemoteAccess { get; set; } - /// <summary> - /// Gets or sets a value indicating whether enable automatic port mapping. - /// </summary> - [Required] - public bool EnableAutomaticPortMapping { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether enable automatic port mapping. + /// </summary> + [Required] + public bool EnableAutomaticPortMapping { get; set; } } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs index e4c9735481..f473bbcef4 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.StartupDtos +namespace Jellyfin.Api.Models.StartupDtos; + +/// <summary> +/// The startup user DTO. +/// </summary> +public class StartupUserDto { /// <summary> - /// The startup user DTO. + /// Gets or sets the username. /// </summary> - public class StartupUserDto - { - /// <summary> - /// Gets or sets the username. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets the user's password. - /// </summary> - public string? Password { get; set; } - } + /// <summary> + /// Gets or sets the user's password. + /// </summary> + public string? Password { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs index 3791fadbe6..4f1abb1ffb 100644 --- a/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The hls video request dto. +/// </summary> +public class HlsAudioRequestDto : StreamingRequestDto { /// <summary> - /// The hls video request dto. + /// Gets or sets a value indicating whether enable adaptive bitrate streaming. /// </summary> - public class HlsAudioRequestDto : StreamingRequestDto - { - /// <summary> - /// Gets or sets a value indicating whether enable adaptive bitrate streaming. - /// </summary> - public bool EnableAdaptiveBitrateStreaming { get; set; } - } + public bool EnableAdaptiveBitrateStreaming { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs index 7a4be091ba..1cd3d01323 100644 --- a/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The hls video request dto. +/// </summary> +public class HlsVideoRequestDto : VideoRequestDto { /// <summary> - /// The hls video request dto. + /// Gets or sets a value indicating whether enable adaptive bitrate streaming. /// </summary> - public class HlsVideoRequestDto : VideoRequestDto - { - /// <summary> - /// Gets or sets a value indicating whether enable adaptive bitrate streaming. - /// </summary> - public bool EnableAdaptiveBitrateStreaming { get; set; } - } + public bool EnableAdaptiveBitrateStreaming { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs index 1fce1d20a3..b75272d3f6 100644 --- a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs +++ b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs @@ -5,192 +5,191 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The stream state dto. +/// </summary> +public class StreamState : EncodingJobInfo, IDisposable { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="StreamState" /> class. + /// </summary> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager" /> interface.</param> + /// <param name="transcodingType">The <see cref="TranscodingJobType" />.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper" /> singleton.</param> + public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper) + : base(transcodingType) + { + _mediaSourceManager = mediaSourceManager; + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Gets or sets the requested url. + /// </summary> + public string? RequestedUrl { get; set; } + /// <summary> - /// The stream state dto. + /// Gets or sets the request. /// </summary> - public class StreamState : EncodingJobInfo, IDisposable + public StreamingRequestDto Request { - private readonly IMediaSourceManager _mediaSourceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="StreamState" /> class. - /// </summary> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager" /> interface.</param> - /// <param name="transcodingType">The <see cref="TranscodingJobType" />.</param> - /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper" /> singleton.</param> - public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper) - : base(transcodingType) + get => (StreamingRequestDto)BaseRequest; + set { - _mediaSourceManager = mediaSourceManager; - _transcodingJobHelper = transcodingJobHelper; + BaseRequest = value; + IsVideoRequest = VideoRequest is not null; } + } + + /// <summary> + /// Gets the video request. + /// </summary> + public VideoRequestDto? VideoRequest => Request as VideoRequestDto; + + /// <summary> + /// Gets or sets the direct stream provicer. + /// </summary> + /// <remarks> + /// Deprecated. + /// </remarks> + public IDirectStreamProvider? DirectStreamProvider { get; set; } + + /// <summary> + /// Gets or sets the path to wait for. + /// </summary> + public string? WaitForPath { get; set; } - /// <summary> - /// Gets or sets the requested url. - /// </summary> - public string? RequestedUrl { get; set; } + /// <summary> + /// Gets a value indicating whether the request outputs video. + /// </summary> + public bool IsOutputVideo => Request is VideoRequestDto; - /// <summary> - /// Gets or sets the request. - /// </summary> - public StreamingRequestDto Request + /// <summary> + /// Gets the segment length. + /// </summary> + public int SegmentLength + { + get { - get => (StreamingRequestDto)BaseRequest; - set + if (Request.SegmentLength.HasValue) { - BaseRequest = value; - IsVideoRequest = VideoRequest is not null; + return Request.SegmentLength.Value; } - } - /// <summary> - /// Gets the video request. - /// </summary> - public VideoRequestDto? VideoRequest => Request as VideoRequestDto; - - /// <summary> - /// Gets or sets the direct stream provicer. - /// </summary> - /// <remarks> - /// Deprecated. - /// </remarks> - public IDirectStreamProvider? DirectStreamProvider { get; set; } - - /// <summary> - /// Gets or sets the path to wait for. - /// </summary> - public string? WaitForPath { get; set; } - - /// <summary> - /// Gets a value indicating whether the request outputs video. - /// </summary> - public bool IsOutputVideo => Request is VideoRequestDto; - - /// <summary> - /// Gets the segment length. - /// </summary> - public int SegmentLength - { - get + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) { - if (Request.SegmentLength.HasValue) + var userAgent = UserAgent ?? string.Empty; + + if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) { - return Request.SegmentLength.Value; + return 6; } - if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) + if (IsSegmentedLiveStream) { - var userAgent = UserAgent ?? string.Empty; - - if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) - { - return 6; - } - - if (IsSegmentedLiveStream) - { - return 3; - } - - return 6; + return 3; } - return 3; + return 6; } + + return 3; } + } - /// <summary> - /// Gets the minimum number of segments. - /// </summary> - public int MinSegments + /// <summary> + /// Gets the minimum number of segments. + /// </summary> + public int MinSegments + { + get { - get + if (Request.MinSegments.HasValue) { - if (Request.MinSegments.HasValue) - { - return Request.MinSegments.Value; - } - - return SegmentLength >= 10 ? 2 : 3; + return Request.MinSegments.Value; } - } - /// <summary> - /// Gets or sets the user agent. - /// </summary> - public string? UserAgent { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to estimate the content length. - /// </summary> - public bool EstimateContentLength { get; set; } - - /// <summary> - /// Gets or sets the transcode seek info. - /// </summary> - public TranscodeSeekInfo TranscodeSeekInfo { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable dlna headers. - /// </summary> - public bool EnableDlnaHeaders { get; set; } - - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } - - /// <summary> - /// Gets or sets the transcoding job. - /// </summary> - public TranscodingJobDto? TranscodingJob { get; set; } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + return SegmentLength >= 10 ? 2 : 3; } + } + + /// <summary> + /// Gets or sets the user agent. + /// </summary> + public string? UserAgent { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to estimate the content length. + /// </summary> + public bool EstimateContentLength { get; set; } + + /// <summary> + /// Gets or sets the transcode seek info. + /// </summary> + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable dlna headers. + /// </summary> + public bool EnableDlnaHeaders { get; set; } + + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } - /// <inheritdoc /> - public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + /// <summary> + /// Gets or sets the transcoding job. + /// </summary> + public TranscodingJobDto? TranscodingJob { get; set; } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <inheritdoc /> + public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + } + + /// <summary> + /// Disposes the stream state. + /// </summary> + /// <param name="disposing">Whether the object is currently being disposed.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) { - _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + return; } - /// <summary> - /// Disposes the stream state. - /// </summary> - /// <param name="disposing">Whether the object is currently being disposed.</param> - protected virtual void Dispose(bool disposing) + if (disposing) { - if (_disposed) + // REVIEW: Is this the right place for this? + if (MediaSource.RequiresClosing + && string.IsNullOrWhiteSpace(Request.LiveStreamId) + && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) { - return; - } - - if (disposing) - { - // REVIEW: Is this the right place for this? - if (MediaSource.RequiresClosing - && string.IsNullOrWhiteSpace(Request.LiveStreamId) - && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) - { - _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); - } + _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); } + } - TranscodingJob = null; + TranscodingJob = null; - _disposed = true; - } + _disposed = true; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs index f8b0212b67..389d6006d0 100644 --- a/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs @@ -1,55 +1,54 @@ using MediaBrowser.Controller.MediaEncoding; -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The audio streaming request dto. +/// </summary> +public class StreamingRequestDto : BaseEncodingJobOptions { /// <summary> - /// The audio streaming request dto. - /// </summary> - public class StreamingRequestDto : BaseEncodingJobOptions - { - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public string? DeviceProfileId { get; set; } - - /// <summary> - /// Gets or sets the params. - /// </summary> - public string? Params { get; set; } - - /// <summary> - /// Gets or sets the play session id. - /// </summary> - public string? PlaySessionId { get; set; } - - /// <summary> - /// Gets or sets the tag. - /// </summary> - public string? Tag { get; set; } - - /// <summary> - /// Gets or sets the segment container. - /// </summary> - public string? SegmentContainer { get; set; } - - /// <summary> - /// Gets or sets the segment length. - /// </summary> - public int? SegmentLength { get; set; } - - /// <summary> - /// Gets or sets the min segments. - /// </summary> - public int? MinSegments { get; set; } - - /// <summary> - /// Gets or sets the position of the requested segment in ticks. - /// </summary> - public long CurrentRuntimeTicks { get; set; } - - /// <summary> - /// Gets or sets the actual segment length in ticks. - /// </summary> - public long ActualSegmentLengthTicks { get; set; } - } + /// Gets or sets the device profile. + /// </summary> + public string? DeviceProfileId { get; set; } + + /// <summary> + /// Gets or sets the params. + /// </summary> + public string? Params { get; set; } + + /// <summary> + /// Gets or sets the play session id. + /// </summary> + public string? PlaySessionId { get; set; } + + /// <summary> + /// Gets or sets the tag. + /// </summary> + public string? Tag { get; set; } + + /// <summary> + /// Gets or sets the segment container. + /// </summary> + public string? SegmentContainer { get; set; } + + /// <summary> + /// Gets or sets the segment length. + /// </summary> + public int? SegmentLength { get; set; } + + /// <summary> + /// Gets or sets the min segments. + /// </summary> + public int? MinSegments { get; set; } + + /// <summary> + /// Gets or sets the position of the requested segment in ticks. + /// </summary> + public long CurrentRuntimeTicks { get; set; } + + /// <summary> + /// Gets or sets the actual segment length in ticks. + /// </summary> + public long ActualSegmentLengthTicks { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs index cce2a89d49..60c529d4ab 100644 --- a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs @@ -1,19 +1,18 @@ -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The video request dto. +/// </summary> +public class VideoRequestDto : StreamingRequestDto { /// <summary> - /// The video request dto. + /// Gets a value indicating whether this instance has fixed resolution. /// </summary> - public class VideoRequestDto : StreamingRequestDto - { - /// <summary> - /// Gets a value indicating whether this instance has fixed resolution. - /// </summary> - /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> - public bool HasFixedResolution => Width.HasValue || Height.HasValue; + /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> + public bool HasFixedResolution => Width.HasValue || Height.HasValue; - /// <summary> - /// Gets or sets a value indicating whether to enable subtitles in the manifest. - /// </summary> - public bool EnableSubtitlesInManifest { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether to enable subtitles in the manifest. + /// </summary> + public bool EnableSubtitlesInManifest { get; set; } } diff --git a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs index be05957987..3c903ea6b5 100644 --- a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs +++ b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs @@ -1,34 +1,33 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.SubtitleDtos +namespace Jellyfin.Api.Models.SubtitleDtos; + +/// <summary> +/// Upload subtitles dto. +/// </summary> +public class UploadSubtitleDto { /// <summary> - /// Upload subtitles dto. + /// Gets or sets the subtitle language. /// </summary> - public class UploadSubtitleDto - { - /// <summary> - /// Gets or sets the subtitle language. - /// </summary> - [Required] - public string Language { get; set; } = string.Empty; + [Required] + public string Language { get; set; } = string.Empty; - /// <summary> - /// Gets or sets the subtitle format. - /// </summary> - [Required] - public string Format { get; set; } = string.Empty; + /// <summary> + /// Gets or sets the subtitle format. + /// </summary> + [Required] + public string Format { get; set; } = string.Empty; - /// <summary> - /// Gets or sets a value indicating whether the subtitle is forced. - /// </summary> - [Required] - public bool IsForced { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the subtitle is forced. + /// </summary> + [Required] + public bool IsForced { get; set; } - /// <summary> - /// Gets or sets the subtitle data. - /// </summary> - [Required] - public string Data { get; set; } = string.Empty; - } + /// <summary> + /// Gets or sets the subtitle data. + /// </summary> + [Required] + public string Data { get; set; } = string.Empty; } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs index 479c440840..e7613911ef 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs @@ -1,42 +1,41 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class BufferRequestDto. +/// </summary> +public class BufferRequestDto { /// <summary> - /// Class BufferRequestDto. + /// Initializes a new instance of the <see cref="BufferRequestDto"/> class. /// </summary> - public class BufferRequestDto + public BufferRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="BufferRequestDto"/> class. - /// </summary> - public BufferRequestDto() - { - PlaylistItemId = Guid.Empty; - } + PlaylistItemId = Guid.Empty; + } - /// <summary> - /// Gets or sets when the request has been made by the client. - /// </summary> - /// <value>The date of the request.</value> - public DateTime When { get; set; } + /// <summary> + /// Gets or sets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; set; } - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the client playback is unpaused. - /// </summary> - /// <value>The client playback status.</value> - public bool IsPlaying { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; set; } - /// <summary> - /// Gets or sets the playlist item identifier of the playing item. - /// </summary> - /// <value>The playlist item identifier.</value> - public Guid PlaylistItemId { get; set; } - } + /// <summary> + /// Gets or sets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs index 4c30b7be43..8ccd831bdd 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs @@ -1,14 +1,13 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class IgnoreWaitRequestDto. +/// </summary> +public class IgnoreWaitRequestDto { /// <summary> - /// Class IgnoreWaitRequestDto. + /// Gets or sets a value indicating whether the client should be ignored. /// </summary> - public class IgnoreWaitRequestDto - { - /// <summary> - /// Gets or sets a value indicating whether the client should be ignored. - /// </summary> - /// <value>The client group-wait status.</value> - public bool IgnoreWait { get; set; } - } + /// <value>The client group-wait status.</value> + public bool IgnoreWait { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs index ed97b8d6a5..89ba511afc 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs @@ -1,16 +1,15 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class JoinGroupRequestDto. +/// </summary> +public class JoinGroupRequestDto { /// <summary> - /// Class JoinGroupRequestDto. + /// Gets or sets the group identifier. /// </summary> - public class JoinGroupRequestDto - { - /// <summary> - /// Gets or sets the group identifier. - /// </summary> - /// <value>The identifier of the group to join.</value> - public Guid GroupId { get; set; } - } + /// <value>The identifier of the group to join.</value> + public Guid GroupId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs index 3af25f3e3e..220d147f20 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs @@ -1,30 +1,29 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class MovePlaylistItemRequestDto. +/// </summary> +public class MovePlaylistItemRequestDto { /// <summary> - /// Class MovePlaylistItemRequestDto. + /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class. /// </summary> - public class MovePlaylistItemRequestDto + public MovePlaylistItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class. - /// </summary> - public MovePlaylistItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } + PlaylistItemId = Guid.Empty; + } - /// <summary> - /// Gets or sets the playlist identifier of the item. - /// </summary> - /// <value>The playlist identifier of the item.</value> - public Guid PlaylistItemId { get; set; } + /// <summary> + /// Gets or sets the playlist identifier of the item. + /// </summary> + /// <value>The playlist identifier of the item.</value> + public Guid PlaylistItemId { get; set; } - /// <summary> - /// Gets or sets the new position. - /// </summary> - /// <value>The new position.</value> - public int NewIndex { get; set; } - } + /// <summary> + /// Gets or sets the new position. + /// </summary> + /// <value>The new position.</value> + public int NewIndex { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs index 441d7be367..32a3bb444c 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs @@ -1,22 +1,21 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class NewGroupRequestDto. +/// </summary> +public class NewGroupRequestDto { /// <summary> - /// Class NewGroupRequestDto. + /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class. /// </summary> - public class NewGroupRequestDto + public NewGroupRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class. - /// </summary> - public NewGroupRequestDto() - { - GroupName = string.Empty; - } - - /// <summary> - /// Gets or sets the group name. - /// </summary> - /// <value>The name of the new group.</value> - public string GroupName { get; set; } + GroupName = string.Empty; } + + /// <summary> + /// Gets or sets the group name. + /// </summary> + /// <value>The name of the new group.</value> + public string GroupName { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs index f59a93f13d..b5223af5df 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs @@ -1,24 +1,23 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class NextItemRequestDto. +/// </summary> +public class NextItemRequestDto { /// <summary> - /// Class NextItemRequestDto. + /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class. /// </summary> - public class NextItemRequestDto + public NextItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class. - /// </summary> - public NextItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } - - /// <summary> - /// Gets or sets the playing item identifier. - /// </summary> - /// <value>The playing item identifier.</value> - public Guid PlaylistItemId { get; set; } + PlaylistItemId = Guid.Empty; } + + /// <summary> + /// Gets or sets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs index c4ac068565..f133950575 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs @@ -1,14 +1,13 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class PingRequestDto. +/// </summary> +public class PingRequestDto { /// <summary> - /// Class PingRequestDto. + /// Gets or sets the ping time. /// </summary> - public class PingRequestDto - { - /// <summary> - /// Gets or sets the ping time. - /// </summary> - /// <value>The ping time.</value> - public long Ping { get; set; } - } + /// <value>The ping time.</value> + public long Ping { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs index 844388cd99..e0edaf5e01 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs @@ -1,37 +1,36 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class PlayRequestDto. +/// </summary> +public class PlayRequestDto { /// <summary> - /// Class PlayRequestDto. + /// Initializes a new instance of the <see cref="PlayRequestDto"/> class. /// </summary> - public class PlayRequestDto + public PlayRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="PlayRequestDto"/> class. - /// </summary> - public PlayRequestDto() - { - PlayingQueue = Array.Empty<Guid>(); - } + PlayingQueue = Array.Empty<Guid>(); + } - /// <summary> - /// Gets or sets the playing queue. - /// </summary> - /// <value>The playing queue.</value> - public IReadOnlyList<Guid> PlayingQueue { get; set; } + /// <summary> + /// Gets or sets the playing queue. + /// </summary> + /// <value>The playing queue.</value> + public IReadOnlyList<Guid> PlayingQueue { get; set; } - /// <summary> - /// Gets or sets the position of the playing item in the queue. - /// </summary> - /// <value>The playing item position.</value> - public int PlayingItemPosition { get; set; } + /// <summary> + /// Gets or sets the position of the playing item in the queue. + /// </summary> + /// <value>The playing item position.</value> + public int PlayingItemPosition { get; set; } - /// <summary> - /// Gets or sets the start position ticks. - /// </summary> - /// <value>The start position ticks.</value> - public long StartPositionTicks { get; set; } - } + /// <summary> + /// Gets or sets the start position ticks. + /// </summary> + /// <value>The start position ticks.</value> + public long StartPositionTicks { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs index 7fd4a49be2..f52bd7f46a 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs @@ -1,24 +1,23 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class PreviousItemRequestDto. +/// </summary> +public class PreviousItemRequestDto { /// <summary> - /// Class PreviousItemRequestDto. + /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class. /// </summary> - public class PreviousItemRequestDto + public PreviousItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class. - /// </summary> - public PreviousItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } - - /// <summary> - /// Gets or sets the playing item identifier. - /// </summary> - /// <value>The playing item identifier.</value> - public Guid PlaylistItemId { get; set; } + PlaylistItemId = Guid.Empty; } + + /// <summary> + /// Gets or sets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs index 2b187f443f..c2c2fba044 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs @@ -2,31 +2,30 @@ using System; using System.Collections.Generic; using MediaBrowser.Model.SyncPlay; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class QueueRequestDto. +/// </summary> +public class QueueRequestDto { /// <summary> - /// Class QueueRequestDto. + /// Initializes a new instance of the <see cref="QueueRequestDto"/> class. /// </summary> - public class QueueRequestDto + public QueueRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="QueueRequestDto"/> class. - /// </summary> - public QueueRequestDto() - { - ItemIds = Array.Empty<Guid>(); - } + ItemIds = Array.Empty<Guid>(); + } - /// <summary> - /// Gets or sets the items to enqueue. - /// </summary> - /// <value>The items to enqueue.</value> - public IReadOnlyList<Guid> ItemIds { get; set; } + /// <summary> + /// Gets or sets the items to enqueue. + /// </summary> + /// <value>The items to enqueue.</value> + public IReadOnlyList<Guid> ItemIds { get; set; } - /// <summary> - /// Gets or sets the mode in which to add the new items. - /// </summary> - /// <value>The enqueue mode.</value> - public GroupQueueMode Mode { get; set; } - } + /// <summary> + /// Gets or sets the mode in which to add the new items. + /// </summary> + /// <value>The enqueue mode.</value> + public GroupQueueMode Mode { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs index d9c193016a..d8be75ef18 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs @@ -1,42 +1,41 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class ReadyRequest. +/// </summary> +public class ReadyRequestDto { /// <summary> - /// Class ReadyRequest. + /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class. /// </summary> - public class ReadyRequestDto + public ReadyRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class. - /// </summary> - public ReadyRequestDto() - { - PlaylistItemId = Guid.Empty; - } + PlaylistItemId = Guid.Empty; + } - /// <summary> - /// Gets or sets when the request has been made by the client. - /// </summary> - /// <value>The date of the request.</value> - public DateTime When { get; set; } + /// <summary> + /// Gets or sets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; set; } - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the client playback is unpaused. - /// </summary> - /// <value>The client playback status.</value> - public bool IsPlaying { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; set; } - /// <summary> - /// Gets or sets the playlist item identifier of the playing item. - /// </summary> - /// <value>The playlist item identifier.</value> - public Guid PlaylistItemId { get; set; } - } + /// <summary> + /// Gets or sets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs index 226a584e1d..2c72342721 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs @@ -1,37 +1,36 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class RemoveFromPlaylistRequestDto. +/// </summary> +public class RemoveFromPlaylistRequestDto { /// <summary> - /// Class RemoveFromPlaylistRequestDto. + /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class. /// </summary> - public class RemoveFromPlaylistRequestDto + public RemoveFromPlaylistRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class. - /// </summary> - public RemoveFromPlaylistRequestDto() - { - PlaylistItemIds = Array.Empty<Guid>(); - } + PlaylistItemIds = Array.Empty<Guid>(); + } - /// <summary> - /// Gets or sets the playlist identifiers of the items. Ignored when clearing the playlist. - /// </summary> - /// <value>The playlist identifiers of the items.</value> - public IReadOnlyList<Guid> PlaylistItemIds { get; set; } + /// <summary> + /// Gets or sets the playlist identifiers of the items. Ignored when clearing the playlist. + /// </summary> + /// <value>The playlist identifiers of the items.</value> + public IReadOnlyList<Guid> PlaylistItemIds { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the entire playlist should be cleared. - /// </summary> - /// <value>Whether the entire playlist should be cleared.</value> - public bool ClearPlaylist { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the entire playlist should be cleared. + /// </summary> + /// <value>Whether the entire playlist should be cleared.</value> + public bool ClearPlaylist { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the playing item should be removed as well. Used only when clearing the playlist. - /// </summary> - /// <value>Whether the playing item should be removed as well.</value> - public bool ClearPlayingItem { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether the playing item should be removed as well. Used only when clearing the playlist. + /// </summary> + /// <value>Whether the playing item should be removed as well.</value> + public bool ClearPlayingItem { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs index b9af0be7ff..f461417e96 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs @@ -1,14 +1,13 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SeekRequestDto. +/// </summary> +public class SeekRequestDto { /// <summary> - /// Class SeekRequestDto. + /// Gets or sets the position ticks. /// </summary> - public class SeekRequestDto - { - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } - } + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs index b937679fc1..40e665039c 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs @@ -1,24 +1,23 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SetPlaylistItemRequestDto. +/// </summary> +public class SetPlaylistItemRequestDto { /// <summary> - /// Class SetPlaylistItemRequestDto. + /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class. /// </summary> - public class SetPlaylistItemRequestDto + public SetPlaylistItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class. - /// </summary> - public SetPlaylistItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } - - /// <summary> - /// Gets or sets the playlist identifier of the playing item. - /// </summary> - /// <value>The playlist identifier of the playing item.</value> - public Guid PlaylistItemId { get; set; } + PlaylistItemId = Guid.Empty; } + + /// <summary> + /// Gets or sets the playlist identifier of the playing item. + /// </summary> + /// <value>The playlist identifier of the playing item.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs index e748fc3e0f..387d1ea777 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs @@ -1,16 +1,15 @@ using MediaBrowser.Model.SyncPlay; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SetRepeatModeRequestDto. +/// </summary> +public class SetRepeatModeRequestDto { /// <summary> - /// Class SetRepeatModeRequestDto. + /// Gets or sets the repeat mode. /// </summary> - public class SetRepeatModeRequestDto - { - /// <summary> - /// Gets or sets the repeat mode. - /// </summary> - /// <value>The repeat mode.</value> - public GroupRepeatMode Mode { get; set; } - } + /// <value>The repeat mode.</value> + public GroupRepeatMode Mode { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs index 0e427f4a4d..a67e3958cf 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs @@ -1,16 +1,15 @@ using MediaBrowser.Model.SyncPlay; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SetShuffleModeRequestDto. +/// </summary> +public class SetShuffleModeRequestDto { /// <summary> - /// Class SetShuffleModeRequestDto. + /// Gets or sets the shuffle mode. /// </summary> - public class SetShuffleModeRequestDto - { - /// <summary> - /// Gets or sets the shuffle mode. - /// </summary> - /// <value>The shuffle mode.</value> - public GroupShuffleMode Mode { get; set; } - } + /// <value>The shuffle mode.</value> + public GroupShuffleMode Mode { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs index 31208264fb..70c18a98a4 100644 --- a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs +++ b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The authenticate user by name request body. +/// </summary> +public class AuthenticateUserByName { /// <summary> - /// The authenticate user by name request body. + /// Gets or sets the username. /// </summary> - public class AuthenticateUserByName - { - /// <summary> - /// Gets or sets the username. - /// </summary> - public string? Username { get; set; } + public string? Username { get; set; } - /// <summary> - /// Gets or sets the plain text password. - /// </summary> - public string? Pw { get; set; } - } + /// <summary> + /// Gets or sets the plain text password. + /// </summary> + public string? Pw { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs index 1c88d36287..4f9fc4e78b 100644 --- a/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs +++ b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs @@ -1,18 +1,20 @@ -namespace Jellyfin.Api.Models.UserDtos +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The create user by name request body. +/// </summary> +public class CreateUserByName { /// <summary> - /// The create user by name request body. + /// Gets or sets the username. /// </summary> - public class CreateUserByName - { - /// <summary> - /// Gets or sets the username. - /// </summary> - public string? Name { get; set; } + [Required] + public required string Name { get; set; } - /// <summary> - /// Gets or sets the password. - /// </summary> - public string? Password { get; set; } - } + /// <summary> + /// Gets or sets the password. + /// </summary> + public string? Password { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs b/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs index b31c6539c6..8ea51af2b9 100644 --- a/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs +++ b/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// Forgot Password request body DTO. +/// </summary> +public class ForgotPasswordDto { /// <summary> - /// Forgot Password request body DTO. + /// Gets or sets the entered username to have its password reset. /// </summary> - public class ForgotPasswordDto - { - /// <summary> - /// Gets or sets the entered username to have its password reset. - /// </summary> - [Required] - public string? EnteredUsername { get; set; } - } + [Required] + public required string EnteredUsername { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs b/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs index 62780e23c5..91b5520ee2 100644 --- a/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs +++ b/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// Forgot Password Pin enter request body DTO. +/// </summary> +public class ForgotPasswordPinDto { /// <summary> - /// Forgot Password Pin enter request body DTO. + /// Gets or sets the entered pin to have the password reset. /// </summary> - public class ForgotPasswordPinDto - { - /// <summary> - /// Gets or sets the entered pin to have the password reset. - /// </summary> - [Required] - public string? Pin { get; set; } - } + [Required] + public required string Pin { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs index 9493c08c28..245002f804 100644 --- a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs +++ b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The quick connect request body. +/// </summary> +public class QuickConnectDto { /// <summary> - /// The quick connect request body. + /// Gets or sets the quick connect secret. /// </summary> - public class QuickConnectDto - { - /// <summary> - /// Gets or sets the quick connect secret. - /// </summary> - [Required] - public string Secret { get; set; } = null!; - } + [Required] + public string Secret { get; set; } = null!; } diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs index 0a173ea1a9..80b6203bc4 100644 --- a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The update user easy password request body. +/// </summary> +public class UpdateUserEasyPassword { /// <summary> - /// The update user easy password request body. + /// Gets or sets the new sha1-hashed password. /// </summary> - public class UpdateUserEasyPassword - { - /// <summary> - /// Gets or sets the new sha1-hashed password. - /// </summary> - public string? NewPassword { get; set; } + public string? NewPassword { get; set; } - /// <summary> - /// Gets or sets the new password. - /// </summary> - public string? NewPw { get; set; } + /// <summary> + /// Gets or sets the new password. + /// </summary> + public string? NewPw { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to reset the password. - /// </summary> - public bool ResetPassword { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs index 8288dbbc44..5347fcc9a2 100644 --- a/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs @@ -1,28 +1,27 @@ -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The update user password request body. +/// </summary> +public class UpdateUserPassword { /// <summary> - /// The update user password request body. + /// Gets or sets the current sha1-hashed password. /// </summary> - public class UpdateUserPassword - { - /// <summary> - /// Gets or sets the current sha1-hashed password. - /// </summary> - public string? CurrentPassword { get; set; } + public string? CurrentPassword { get; set; } - /// <summary> - /// Gets or sets the current plain text password. - /// </summary> - public string? CurrentPw { get; set; } + /// <summary> + /// Gets or sets the current plain text password. + /// </summary> + public string? CurrentPw { get; set; } - /// <summary> - /// Gets or sets the new plain text password. - /// </summary> - public string? NewPw { get; set; } + /// <summary> + /// Gets or sets the new plain text password. + /// </summary> + public string? NewPw { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to reset the password. - /// </summary> - public bool ResetPassword { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } } diff --git a/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs b/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs index 84b6b0958c..314b6a3248 100644 --- a/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs +++ b/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.UserViewDtos +namespace Jellyfin.Api.Models.UserViewDtos; + +/// <summary> +/// Special view option dto. +/// </summary> +public class SpecialViewOptionDto { /// <summary> - /// Special view option dto. + /// Gets or sets view option name. /// </summary> - public class SpecialViewOptionDto - { - /// <summary> - /// Gets or sets view option name. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets view option id. - /// </summary> - public string? Id { get; set; } - } + /// <summary> + /// Gets or sets view option id. + /// </summary> + public string? Id { get; set; } } diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index 288e03fcff..4a5e0ecd4f 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -6,59 +6,58 @@ using MediaBrowser.Model.Activity; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.WebSocketListeners +namespace Jellyfin.Api.WebSocketListeners; + +/// <summary> +/// Class SessionInfoWebSocketListener. +/// </summary> +public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<ActivityLogEntry[], WebSocketListenerState> { /// <summary> - /// Class SessionInfoWebSocketListener. + /// The _kernel. /// </summary> - public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<ActivityLogEntry[], WebSocketListenerState> - { - /// <summary> - /// The _kernel. - /// </summary> - private readonly IActivityManager _activityManager; + private readonly IActivityManager _activityManager; - /// <summary> - /// Initializes a new instance of the <see cref="ActivityLogWebSocketListener"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{ActivityLogWebSocketListener}"/> interface.</param> - /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> - public ActivityLogWebSocketListener(ILogger<ActivityLogWebSocketListener> logger, IActivityManager activityManager) - : base(logger) - { - _activityManager = activityManager; - _activityManager.EntryCreated += OnEntryCreated; - } + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogWebSocketListener"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ActivityLogWebSocketListener}"/> interface.</param> + /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> + public ActivityLogWebSocketListener(ILogger<ActivityLogWebSocketListener> logger, IActivityManager activityManager) + : base(logger) + { + _activityManager = activityManager; + _activityManager.EntryCreated += OnEntryCreated; + } - /// <inheritdoc /> - protected override SessionMessageType Type => SessionMessageType.ActivityLogEntry; + /// <inheritdoc /> + protected override SessionMessageType Type => SessionMessageType.ActivityLogEntry; - /// <inheritdoc /> - protected override SessionMessageType StartType => SessionMessageType.ActivityLogEntryStart; + /// <inheritdoc /> + protected override SessionMessageType StartType => SessionMessageType.ActivityLogEntryStart; - /// <inheritdoc /> - protected override SessionMessageType StopType => SessionMessageType.ActivityLogEntryStop; + /// <inheritdoc /> + protected override SessionMessageType StopType => SessionMessageType.ActivityLogEntryStop; - /// <summary> - /// Gets the data to send. - /// </summary> - /// <returns>Task{SystemInfo}.</returns> - protected override Task<ActivityLogEntry[]> GetDataToSend() - { - return Task.FromResult(Array.Empty<ActivityLogEntry>()); - } + /// <summary> + /// Gets the data to send. + /// </summary> + /// <returns>Task{SystemInfo}.</returns> + protected override Task<ActivityLogEntry[]> GetDataToSend() + { + return Task.FromResult(Array.Empty<ActivityLogEntry>()); + } - /// <inheritdoc /> - protected override void Dispose(bool dispose) - { - _activityManager.EntryCreated -= OnEntryCreated; + /// <inheritdoc /> + protected override void Dispose(bool dispose) + { + _activityManager.EntryCreated -= OnEntryCreated; - base.Dispose(dispose); - } + base.Dispose(dispose); + } - private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) - { - SendData(true).GetAwaiter().GetResult(); - } + private async void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) + { + await SendData(true).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs index 7c6ce3273e..a9df2d6712 100644 --- a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs @@ -7,78 +7,77 @@ using MediaBrowser.Model.Session; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.WebSocketListeners +namespace Jellyfin.Api.WebSocketListeners; + +/// <summary> +/// Class ScheduledTasksWebSocketListener. +/// </summary> +public class ScheduledTasksWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<TaskInfo>, WebSocketListenerState> { /// <summary> - /// Class ScheduledTasksWebSocketListener. + /// Gets or sets the task manager. /// </summary> - public class ScheduledTasksWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<TaskInfo>, WebSocketListenerState> - { - /// <summary> - /// Gets or sets the task manager. - /// </summary> - /// <value>The task manager.</value> - private readonly ITaskManager _taskManager; + /// <value>The task manager.</value> + private readonly ITaskManager _taskManager; - /// <summary> - /// Initializes a new instance of the <see cref="ScheduledTasksWebSocketListener"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{ScheduledTasksWebSocketListener}"/> interface.</param> - /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> - public ScheduledTasksWebSocketListener(ILogger<ScheduledTasksWebSocketListener> logger, ITaskManager taskManager) - : base(logger) - { - _taskManager = taskManager; + /// <summary> + /// Initializes a new instance of the <see cref="ScheduledTasksWebSocketListener"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ScheduledTasksWebSocketListener}"/> interface.</param> + /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> + public ScheduledTasksWebSocketListener(ILogger<ScheduledTasksWebSocketListener> logger, ITaskManager taskManager) + : base(logger) + { + _taskManager = taskManager; - _taskManager.TaskExecuting += OnTaskExecuting; - _taskManager.TaskCompleted += OnTaskCompleted; - } + _taskManager.TaskExecuting += OnTaskExecuting; + _taskManager.TaskCompleted += OnTaskCompleted; + } - /// <inheritdoc /> - protected override SessionMessageType Type => SessionMessageType.ScheduledTasksInfo; + /// <inheritdoc /> + protected override SessionMessageType Type => SessionMessageType.ScheduledTasksInfo; - /// <inheritdoc /> - protected override SessionMessageType StartType => SessionMessageType.ScheduledTasksInfoStart; + /// <inheritdoc /> + protected override SessionMessageType StartType => SessionMessageType.ScheduledTasksInfoStart; - /// <inheritdoc /> - protected override SessionMessageType StopType => SessionMessageType.ScheduledTasksInfoStop; + /// <inheritdoc /> + protected override SessionMessageType StopType => SessionMessageType.ScheduledTasksInfoStop; - /// <summary> - /// Gets the data to send. - /// </summary> - /// <returns>Task{IEnumerable{TaskInfo}}.</returns> - protected override Task<IEnumerable<TaskInfo>> GetDataToSend() - { - return Task.FromResult(_taskManager.ScheduledTasks - .OrderBy(i => i.Name) - .Select(ScheduledTaskHelpers.GetTaskInfo) - .Where(i => !i.IsHidden)); - } + /// <summary> + /// Gets the data to send. + /// </summary> + /// <returns>Task{IEnumerable{TaskInfo}}.</returns> + protected override Task<IEnumerable<TaskInfo>> GetDataToSend() + { + return Task.FromResult(_taskManager.ScheduledTasks + .OrderBy(i => i.Name) + .Select(ScheduledTaskHelpers.GetTaskInfo) + .Where(i => !i.IsHidden)); + } - /// <inheritdoc /> - protected override void Dispose(bool dispose) - { - _taskManager.TaskExecuting -= OnTaskExecuting; - _taskManager.TaskCompleted -= OnTaskCompleted; + /// <inheritdoc /> + protected override void Dispose(bool dispose) + { + _taskManager.TaskExecuting -= OnTaskExecuting; + _taskManager.TaskCompleted -= OnTaskCompleted; - base.Dispose(dispose); - } + base.Dispose(dispose); + } - private async void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) - { - e.Task.TaskProgress -= OnTaskProgress; - await SendData(true).ConfigureAwait(false); - } + private async void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) + { + e.Task.TaskProgress -= OnTaskProgress; + await SendData(true).ConfigureAwait(false); + } - private async void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) - { - await SendData(true).ConfigureAwait(false); - e.Argument.TaskProgress += OnTaskProgress; - } + private async void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) + { + await SendData(true).ConfigureAwait(false); + e.Argument.TaskProgress += OnTaskProgress; + } - private async void OnTaskProgress(object? sender, GenericEventArgs<double> e) - { - await SendData(false).ConfigureAwait(false); - } + private async void OnTaskProgress(object? sender, GenericEventArgs<double> e) + { + await SendData(false).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index d996ac69f9..0d8bf205c9 100644 --- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -6,99 +6,98 @@ using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.WebSocketListeners +namespace Jellyfin.Api.WebSocketListeners; + +/// <summary> +/// Class SessionInfoWebSocketListener. +/// </summary> +public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState> { + private readonly ISessionManager _sessionManager; + /// <summary> - /// Class SessionInfoWebSocketListener. + /// Initializes a new instance of the <see cref="SessionInfoWebSocketListener"/> class. /// </summary> - public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState> + /// <param name="logger">Instance of the <see cref="ILogger{SessionInfoWebSocketListener}"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + public SessionInfoWebSocketListener(ILogger<SessionInfoWebSocketListener> logger, ISessionManager sessionManager) + : base(logger) + { + _sessionManager = sessionManager; + + _sessionManager.SessionStarted += OnSessionManagerSessionStarted; + _sessionManager.SessionEnded += OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart += OnSessionManagerPlaybackStart; + _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; + _sessionManager.PlaybackProgress += OnSessionManagerPlaybackProgress; + _sessionManager.CapabilitiesChanged += OnSessionManagerCapabilitiesChanged; + _sessionManager.SessionActivity += OnSessionManagerSessionActivity; + } + + /// <inheritdoc /> + protected override SessionMessageType Type => SessionMessageType.Sessions; + + /// <inheritdoc /> + protected override SessionMessageType StartType => SessionMessageType.SessionsStart; + + /// <inheritdoc /> + protected override SessionMessageType StopType => SessionMessageType.SessionsStop; + + /// <summary> + /// Gets the data to send. + /// </summary> + /// <returns>Task{SystemInfo}.</returns> + protected override Task<IEnumerable<SessionInfo>> GetDataToSend() + { + return Task.FromResult(_sessionManager.Sessions); + } + + /// <inheritdoc /> + protected override void Dispose(bool dispose) + { + _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; + _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; + _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; + _sessionManager.PlaybackProgress -= OnSessionManagerPlaybackProgress; + _sessionManager.CapabilitiesChanged -= OnSessionManagerCapabilitiesChanged; + _sessionManager.SessionActivity -= OnSessionManagerSessionActivity; + + base.Dispose(dispose); + } + + private async void OnSessionManagerSessionActivity(object? sender, SessionEventArgs e) + { + await SendData(false).ConfigureAwait(false); + } + + private async void OnSessionManagerCapabilitiesChanged(object? sender, SessionEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerPlaybackProgress(object? sender, PlaybackProgressEventArgs e) + { + await SendData(!e.IsAutomated).ConfigureAwait(false); + } + + private async void OnSessionManagerPlaybackStopped(object? sender, PlaybackStopEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerPlaybackStart(object? sender, PlaybackProgressEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerSessionEnded(object? sender, SessionEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerSessionStarted(object? sender, SessionEventArgs e) { - private readonly ISessionManager _sessionManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SessionInfoWebSocketListener"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{SessionInfoWebSocketListener}"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - public SessionInfoWebSocketListener(ILogger<SessionInfoWebSocketListener> logger, ISessionManager sessionManager) - : base(logger) - { - _sessionManager = sessionManager; - - _sessionManager.SessionStarted += OnSessionManagerSessionStarted; - _sessionManager.SessionEnded += OnSessionManagerSessionEnded; - _sessionManager.PlaybackStart += OnSessionManagerPlaybackStart; - _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; - _sessionManager.PlaybackProgress += OnSessionManagerPlaybackProgress; - _sessionManager.CapabilitiesChanged += OnSessionManagerCapabilitiesChanged; - _sessionManager.SessionActivity += OnSessionManagerSessionActivity; - } - - /// <inheritdoc /> - protected override SessionMessageType Type => SessionMessageType.Sessions; - - /// <inheritdoc /> - protected override SessionMessageType StartType => SessionMessageType.SessionsStart; - - /// <inheritdoc /> - protected override SessionMessageType StopType => SessionMessageType.SessionsStop; - - /// <summary> - /// Gets the data to send. - /// </summary> - /// <returns>Task{SystemInfo}.</returns> - protected override Task<IEnumerable<SessionInfo>> GetDataToSend() - { - return Task.FromResult(_sessionManager.Sessions); - } - - /// <inheritdoc /> - protected override void Dispose(bool dispose) - { - _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; - _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; - _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; - _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; - _sessionManager.PlaybackProgress -= OnSessionManagerPlaybackProgress; - _sessionManager.CapabilitiesChanged -= OnSessionManagerCapabilitiesChanged; - _sessionManager.SessionActivity -= OnSessionManagerSessionActivity; - - base.Dispose(dispose); - } - - private async void OnSessionManagerSessionActivity(object? sender, SessionEventArgs e) - { - await SendData(false).ConfigureAwait(false); - } - - private async void OnSessionManagerCapabilitiesChanged(object? sender, SessionEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerPlaybackProgress(object? sender, PlaybackProgressEventArgs e) - { - await SendData(!e.IsAutomated).ConfigureAwait(false); - } - - private async void OnSessionManagerPlaybackStopped(object? sender, PlaybackStopEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerPlaybackStart(object? sender, PlaybackProgressEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerSessionEnded(object? sender, SessionEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerSessionStarted(object? sender, SessionEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } + await SendData(true).ConfigureAwait(false); } } diff --git a/Jellyfin.Data/DayOfWeekHelper.cs b/Jellyfin.Data/DayOfWeekHelper.cs index b7ba30180e..82abfb8313 100644 --- a/Jellyfin.Data/DayOfWeekHelper.cs +++ b/Jellyfin.Data/DayOfWeekHelper.cs @@ -17,5 +17,16 @@ namespace Jellyfin.Data _ => new[] { (DayOfWeek)day } }; } + + public static bool Contains(this DynamicDayOfWeek dynamicDayOfWeek, DayOfWeek dayOfWeek) + { + return dynamicDayOfWeek switch + { + DynamicDayOfWeek.Everyday => true, + DynamicDayOfWeek.Weekday => dayOfWeek is >= DayOfWeek.Monday and <= DayOfWeek.Friday, + DynamicDayOfWeek.Weekend => dayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday, + _ => (DayOfWeek)dynamicDayOfWeek == dayOfWeek + }; + } } } diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index eb59e70f3b..58ddaaf83a 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -92,16 +92,6 @@ namespace Jellyfin.Data.Entities public string? Password { get; set; } /// <summary> - /// Gets or sets the user's easy password, or <c>null</c> if none is set. - /// </summary> - /// <remarks> - /// Max length = 65535. - /// </remarks> - [MaxLength(65535)] - [StringLength(65535)] - public string? EasyPassword { get; set; } - - /// <summary> /// Gets or sets a value indicating whether the user must update their password. /// </summary> /// <remarks> @@ -508,6 +498,7 @@ namespace Jellyfin.Data.Entities Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true)); Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false)); Permissions.Add(new Permission(PermissionKind.EnableRemoteControlOfOtherUsers, false)); + Permissions.Add(new Permission(PermissionKind.EnableCollectionManagement, false)); } /// <summary> @@ -525,8 +516,9 @@ namespace Jellyfin.Data.Entities { var localTime = date.ToLocalTime(); var hour = localTime.TimeOfDay.TotalHours; + var currentDayOfWeek = localTime.DayOfWeek; - return DayOfWeekHelper.GetDaysOfWeek(schedule.DayOfWeek).Contains(localTime.DayOfWeek) + return schedule.DayOfWeek.Contains(currentDayOfWeek) && hour >= schedule.StartHour && hour <= schedule.EndHour; } diff --git a/Jellyfin.Data/Enums/PermissionKind.cs b/Jellyfin.Data/Enums/PermissionKind.cs index 7d52008747..40280b95ef 100644 --- a/Jellyfin.Data/Enums/PermissionKind.cs +++ b/Jellyfin.Data/Enums/PermissionKind.cs @@ -108,6 +108,11 @@ namespace Jellyfin.Data.Enums /// <summary> /// Whether the server should force transcoding on remote connections for the user. /// </summary> - ForceRemoteSourceTranscoding = 20 + ForceRemoteSourceTranscoding = 20, + + /// <summary> + /// Whether the user can create, modify and delete collections. + /// </summary> + EnableCollectionManagement = 21 } } diff --git a/Jellyfin.Data/Enums/PersonKind.cs b/Jellyfin.Data/Enums/PersonKind.cs new file mode 100644 index 0000000000..10a8056669 --- /dev/null +++ b/Jellyfin.Data/Enums/PersonKind.cs @@ -0,0 +1,97 @@ +namespace Jellyfin.Data.Enums; + +/// <summary> +/// The person kind. +/// </summary> +public enum PersonKind +{ + /// <summary> + /// An unknown person kind. + /// </summary> + Unknown, + + /// <summary> + /// A person whose profession is acting on the stage, in films, or on television. + /// </summary> + Actor, + + /// <summary> + /// A person who supervises the actors and other staff in a film, play, or similar production. + /// </summary> + Director, + + /// <summary> + /// A person who writes music, especially as a professional occupation. + /// </summary> + Composer, + + /// <summary> + /// A writer of a book, article, or document. Can also be used as a generic term for music writer if there is a lack of specificity. + /// </summary> + Writer, + + /// <summary> + /// A well-known actor or other performer who appears in a work in which they do not have a regular role. + /// </summary> + GuestStar, + + /// <summary> + /// A person responsible for the financial and managerial aspects of the making of a film or broadcast or for staging a play, opera, etc. + /// </summary> + Producer, + + /// <summary> + /// A person who directs the performance of an orchestra or choir. + /// </summary> + Conductor, + + /// <summary> + /// A person who writes the words to a song or musical. + /// </summary> + Lyricist, + + /// <summary> + /// A person who adapts a musical composition for performance. + /// </summary> + Arranger, + + /// <summary> + /// An audio engineer who performed a general engineering role. + /// </summary> + Engineer, + + /// <summary> + /// An engineer responsible for using a mixing console to mix a recorded track into a single piece of music suitable for release. + /// </summary> + Mixer, + + /// <summary> + /// A person who remixed a recording by taking one or more other tracks, substantially altering them and mixing them together with other material. + /// </summary> + Remixer, + + /// <summary> + /// A person who created the material. + /// </summary> + Creator, + + /// <summary> + /// A person who was the artist. + /// </summary> + Artist, + + /// <summary> + /// A person who was the album artist. + /// </summary> + AlbumArtist, + + /// <summary> + /// A person who was the author. + /// </summary> + Author, + + /// <summary> + /// A person who was the illustrator. + /// </summary> + Illustrator, +} diff --git a/Jellyfin.Data/Enums/PreferenceKind.cs b/Jellyfin.Data/Enums/PreferenceKind.cs index a54d789afb..d2b412e459 100644 --- a/Jellyfin.Data/Enums/PreferenceKind.cs +++ b/Jellyfin.Data/Enums/PreferenceKind.cs @@ -63,6 +63,11 @@ namespace Jellyfin.Data.Enums /// <summary> /// A list of ordered views. /// </summary> - OrderedViews = 11 + OrderedViews = 11, + + /// <summary> + /// A list of allowed tags. + /// </summary> + AllowedTags = 12 } } diff --git a/Jellyfin.Data/Enums/VideoRange.cs b/Jellyfin.Data/Enums/VideoRange.cs new file mode 100644 index 0000000000..5072e5ba3e --- /dev/null +++ b/Jellyfin.Data/Enums/VideoRange.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Data.Enums; + +/// <summary> +/// An enum representing video ranges. +/// </summary> +public enum VideoRange +{ + /// <summary> + /// Unknown video range. + /// </summary> + Unknown, + + /// <summary> + /// SDR video range. + /// </summary> + SDR, + + /// <summary> + /// HDR video range. + /// </summary> + HDR +} diff --git a/Jellyfin.Data/Enums/VideoRangeType.cs b/Jellyfin.Data/Enums/VideoRangeType.cs new file mode 100644 index 0000000000..7ac7bc20a3 --- /dev/null +++ b/Jellyfin.Data/Enums/VideoRangeType.cs @@ -0,0 +1,37 @@ +namespace Jellyfin.Data.Enums; + +/// <summary> +/// An enum representing types of video ranges. +/// </summary> +public enum VideoRangeType +{ + /// <summary> + /// Unknown video range type. + /// </summary> + Unknown, + + /// <summary> + /// SDR video range type (8bit). + /// </summary> + SDR, + + /// <summary> + /// HDR10 video range type (10bit). + /// </summary> + HDR10, + + /// <summary> + /// HLG video range type (10bit). + /// </summary> + HLG, + + /// <summary> + /// Dolby Vision video range type (12bit). + /// </summary> + DOVI, + + /// <summary> + /// HDR10+ video range type (10bit to 16bit). + /// </summary> + HDR10Plus +} diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 540534e1ba..1bc5d8bf91 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -24,22 +24,22 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" /> </ItemGroup> <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" /> + <PackageReference Include="Microsoft.Extensions.Logging" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs b/Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs new file mode 100644 index 0000000000..59e6956c71 --- /dev/null +++ b/Jellyfin.Networking/HappyEyeballs/HttpClientExtension.cs @@ -0,0 +1,120 @@ +/* +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +using System.IO; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Networking.HappyEyeballs +{ + /// <summary> + /// Defines the <see cref="HttpClientExtension"/> class. + /// + /// Implementation taken from https://github.com/ppy/osu-framework/pull/4191 . + /// </summary> + public static class HttpClientExtension + { + /// <summary> + /// Gets or sets a value indicating whether the client should use IPv6. + /// </summary> + public static bool UseIPv6 { get; set; } = true; + + /// <summary> + /// Implements the httpclient callback method. + /// </summary> + /// <param name="context">The <see cref="SocketsHttpConnectionContext"/> instance.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/> instance.</param> + /// <returns>The http steam.</returns> + public static async ValueTask<Stream> OnConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + if (!UseIPv6) + { + return await AttemptConnection(AddressFamily.InterNetwork, context, cancellationToken).ConfigureAwait(false); + } + + using var cancelIPv6 = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tryConnectAsyncIPv6 = AttemptConnection(AddressFamily.InterNetworkV6, context, cancelIPv6.Token); + + // GetAwaiter().GetResult() is used instead of .Result as this results in improved exception handling. + // The tasks have already been completed. + // See https://github.com/dotnet/corefx/pull/29792/files#r189415885 for more details. + if (await Task.WhenAny(tryConnectAsyncIPv6, Task.Delay(200, cancelIPv6.Token)).ConfigureAwait(false) == tryConnectAsyncIPv6 && tryConnectAsyncIPv6.IsCompletedSuccessfully) + { + cancelIPv6.Cancel(); + return tryConnectAsyncIPv6.GetAwaiter().GetResult(); + } + + using var cancelIPv4 = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tryConnectAsyncIPv4 = AttemptConnection(AddressFamily.InterNetwork, context, cancelIPv4.Token); + + if (await Task.WhenAny(tryConnectAsyncIPv6, tryConnectAsyncIPv4).ConfigureAwait(false) == tryConnectAsyncIPv6) + { + if (tryConnectAsyncIPv6.IsCompletedSuccessfully) + { + cancelIPv4.Cancel(); + return tryConnectAsyncIPv6.GetAwaiter().GetResult(); + } + + return tryConnectAsyncIPv4.GetAwaiter().GetResult(); + } + else + { + if (tryConnectAsyncIPv4.IsCompletedSuccessfully) + { + cancelIPv6.Cancel(); + return tryConnectAsyncIPv4.GetAwaiter().GetResult(); + } + + return tryConnectAsyncIPv6.GetAwaiter().GetResult(); + } + } + + private static async Task<Stream> AttemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + // The following socket constructor will create a dual-mode socket on systems where IPV6 is available. + var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp) + { + // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. + NoDelay = true + }; + + try + { + await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false); + // The stream should take the ownership of the underlying socket, + // closing it when it's disposed. + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + } +} diff --git a/Jellyfin.Networking/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj index 2c153d88b5..4cff5927fd 100644 --- a/Jellyfin.Networking/Jellyfin.Networking.csproj +++ b/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -11,13 +11,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs index 86989bfde6..afb0538205 100644 --- a/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/Jellyfin.Networking/Manager/NetworkManager.cs @@ -316,7 +316,7 @@ namespace Jellyfin.Networking.Manager /// <inheritdoc/> public string GetBindInterface(string source, out int? port) { - if (!string.IsNullOrEmpty(source) && IPHost.TryParse(source, out IPHost host)) + if (IPHost.TryParse(source, out IPHost host)) { return GetBindInterface(host, out port); } @@ -500,10 +500,8 @@ namespace Jellyfin.Networking.Manager { return true; } - else - { - return address.IsPrivateAddressRange(); - } + + return address.IsPrivateAddressRange(); } /// <inheritdoc/> @@ -594,6 +592,7 @@ namespace Jellyfin.Networking.Manager IsIP4Enabled = Socket.OSSupportsIPv4 && config.EnableIPV4; IsIP6Enabled = Socket.OSSupportsIPv6 && config.EnableIPV6; + HappyEyeballs.HttpClientExtension.UseIPv6 = IsIP6Enabled; if (!IsIP6Enabled && !IsIP4Enabled) { @@ -838,9 +837,19 @@ namespace Jellyfin.Networking.Manager try { await Task.Delay(2000).ConfigureAwait(false); - InitialiseInterfaces(); - // Recalculate LAN caches. - InitialiseLAN(_configurationManager.GetNetworkConfiguration()); + + var config = _configurationManager.GetNetworkConfiguration(); + // Have we lost IPv6 capability? + if (IsIP6Enabled && !Socket.OSSupportsIPv6) + { + UpdateSettings(config); + } + else + { + InitialiseInterfaces(); + // Recalculate LAN caches. + InitialiseLAN(config); + } NetworkChanged?.Invoke(this, EventArgs.Empty); } @@ -1019,8 +1028,8 @@ namespace Jellyfin.Networking.Manager _internalInterfaces = CreateCollection(_interfaceAddresses.Where(IsInLocalNetwork)); } - _logger.LogInformation("Defined LAN addresses : {0}", _lanSubnets.AsString()); - _logger.LogInformation("Defined LAN exclusions : {0}", _excludedSubnets.AsString()); + _logger.LogInformation("Defined LAN addresses: {0}", _lanSubnets.AsString()); + _logger.LogInformation("Defined LAN exclusions: {0}", _excludedSubnets.AsString()); _logger.LogInformation("Using LAN addresses: {0}", _lanSubnets.Exclude(_excludedSubnets, true).AsNetworks().AsString()); } } @@ -1145,7 +1154,7 @@ namespace Jellyfin.Networking.Manager } _logger.LogDebug("Discovered {0} interfaces.", _interfaceAddresses.Count); - _logger.LogDebug("Interfaces addresses : {0}", _interfaceAddresses.AsString()); + _logger.LogDebug("Interfaces addresses: {0}", _interfaceAddresses.AsString()); } } @@ -1171,13 +1180,15 @@ namespace Jellyfin.Networking.Manager bindPreference = addr.Value; break; } - else if ((addr.Key.Address.Equals(IPAddress.Any) || addr.Key.Address.Equals(IPAddress.IPv6Any)) && isInExternalSubnet) + + if ((addr.Key.Address.Equals(IPAddress.Any) || addr.Key.Address.Equals(IPAddress.IPv6Any)) && isInExternalSubnet) { // External. bindPreference = addr.Value; break; } - else if (addr.Key.Contains(source)) + + if (addr.Key.Contains(source)) { // Match ip address. bindPreference = addr.Value; @@ -1256,8 +1267,7 @@ namespace Jellyfin.Networking.Manager // Look for the best internal address. bindAddress = addresses .Where(p => IsInLocalNetwork(p) && (p.Contains(source) || p.Equals(IPAddress.None))) - .OrderBy(p => p.Tag) - .FirstOrDefault()?.Address; + .MinBy(p => p.Tag)?.Address; } if (bindAddress is not null) diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 8b15d6823d..a4b4c19599 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -9,6 +9,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; using Jellyfin.Extensions; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Devices; @@ -185,6 +186,10 @@ namespace Jellyfin.Server.Implementations.Devices if (userId.HasValue) { var user = _userManager.GetUserById(userId.Value); + if (user is null) + { + throw new ResourceNotFoundException(); + } sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)); } diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index b078db0169..390ed58b3b 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -8,13 +8,13 @@ <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> @@ -22,15 +22,15 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.2" /> - <PackageReference Include="System.Linq.Async" Version="6.0.1" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.2" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.2" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2"> + <PackageReference Include="EFCoreSecondLevelCacheInterceptor" /> + <PackageReference Include="System.Linq.Async" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.2"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.Designer.cs new file mode 100644 index 0000000000..00ccd9f0ff --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.Designer.cs @@ -0,0 +1,650 @@ +// <auto-generated /> +using System; +using Jellyfin.Server.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("20230526173516_RemoveEasyPassword")] + partial class RemoveEasyPassword + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.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"); + }); + + modelBuilder.Entity("Jellyfin.Data.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<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?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + 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") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.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/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.cs b/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.cs new file mode 100644 index 0000000000..9496ff3c0d --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230526173516_RemoveEasyPassword.cs @@ -0,0 +1,164 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class RemoveEasyPassword : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EasyPassword", + schema: "jellyfin", + table: "Users"); + + migrationBuilder.RenameTable( + name: "Users", + schema: "jellyfin", + newName: "Users"); + + migrationBuilder.RenameTable( + name: "Preferences", + schema: "jellyfin", + newName: "Preferences"); + + migrationBuilder.RenameTable( + name: "Permissions", + schema: "jellyfin", + newName: "Permissions"); + + migrationBuilder.RenameTable( + name: "ItemDisplayPreferences", + schema: "jellyfin", + newName: "ItemDisplayPreferences"); + + migrationBuilder.RenameTable( + name: "ImageInfos", + schema: "jellyfin", + newName: "ImageInfos"); + + migrationBuilder.RenameTable( + name: "HomeSection", + schema: "jellyfin", + newName: "HomeSection"); + + migrationBuilder.RenameTable( + name: "DisplayPreferences", + schema: "jellyfin", + newName: "DisplayPreferences"); + + migrationBuilder.RenameTable( + name: "Devices", + schema: "jellyfin", + newName: "Devices"); + + migrationBuilder.RenameTable( + name: "DeviceOptions", + schema: "jellyfin", + newName: "DeviceOptions"); + + migrationBuilder.RenameTable( + name: "CustomItemDisplayPreferences", + schema: "jellyfin", + newName: "CustomItemDisplayPreferences"); + + migrationBuilder.RenameTable( + name: "ApiKeys", + schema: "jellyfin", + newName: "ApiKeys"); + + migrationBuilder.RenameTable( + name: "ActivityLogs", + schema: "jellyfin", + newName: "ActivityLogs"); + + migrationBuilder.RenameTable( + name: "AccessSchedules", + schema: "jellyfin", + newName: "AccessSchedules"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "jellyfin"); + + migrationBuilder.RenameTable( + name: "Users", + newName: "Users", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "Preferences", + newName: "Preferences", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "Permissions", + newName: "Permissions", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "ItemDisplayPreferences", + newName: "ItemDisplayPreferences", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "ImageInfos", + newName: "ImageInfos", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "HomeSection", + newName: "HomeSection", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "DisplayPreferences", + newName: "DisplayPreferences", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "Devices", + newName: "Devices", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "DeviceOptions", + newName: "DeviceOptions", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "CustomItemDisplayPreferences", + newName: "CustomItemDisplayPreferences", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "ApiKeys", + newName: "ApiKeys", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "ActivityLogs", + newName: "ActivityLogs", + newSchema: "jellyfin"); + + migrationBuilder.RenameTable( + name: "AccessSchedules", + newName: "AccessSchedules", + newSchema: "jellyfin"); + + migrationBuilder.AddColumn<string>( + name: "EasyPassword", + schema: "jellyfin", + table: "Users", + type: "TEXT", + maxLength: 65535, + nullable: true); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index dd5f7f0121..d23508096f 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -15,9 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("jellyfin") - .HasAnnotation("ProductVersion", "6.0.9"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -41,7 +39,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId"); - b.ToTable("AccessSchedules", "jellyfin"); + b.ToTable("AccessSchedules"); }); modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => @@ -89,7 +87,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("DateCreated"); - b.ToTable("ActivityLogs", "jellyfin"); + b.ToTable("ActivityLogs"); }); modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => @@ -121,7 +119,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId", "ItemId", "Client", "Key") .IsUnique(); - b.ToTable("CustomItemDisplayPreferences", "jellyfin"); + b.ToTable("CustomItemDisplayPreferences"); }); modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => @@ -178,7 +176,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId", "ItemId", "Client") .IsUnique(); - b.ToTable("DisplayPreferences", "jellyfin"); + b.ToTable("DisplayPreferences"); }); modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => @@ -200,7 +198,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("DisplayPreferencesId"); - b.ToTable("HomeSection", "jellyfin"); + b.ToTable("HomeSection"); }); modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => @@ -225,7 +223,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId") .IsUnique(); - b.ToTable("ImageInfos", "jellyfin"); + b.ToTable("ImageInfos"); }); modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => @@ -269,7 +267,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId"); - b.ToTable("ItemDisplayPreferences", "jellyfin"); + b.ToTable("ItemDisplayPreferences"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => @@ -300,7 +298,7 @@ namespace Jellyfin.Server.Implementations.Migrations .IsUnique() .HasFilter("[UserId] IS NOT NULL"); - b.ToTable("Permissions", "jellyfin"); + b.ToTable("Permissions"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => @@ -333,7 +331,7 @@ namespace Jellyfin.Server.Implementations.Migrations .IsUnique() .HasFilter("[UserId] IS NOT NULL"); - b.ToTable("Preferences", "jellyfin"); + b.ToTable("Preferences"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => @@ -362,7 +360,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("AccessToken") .IsUnique(); - b.ToTable("ApiKeys", "jellyfin"); + b.ToTable("ApiKeys"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => @@ -420,7 +418,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId", "DeviceId"); - b.ToTable("Devices", "jellyfin"); + b.ToTable("Devices"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => @@ -441,7 +439,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("DeviceId") .IsUnique(); - b.ToTable("DeviceOptions", "jellyfin"); + b.ToTable("DeviceOptions"); }); modelBuilder.Entity("Jellyfin.Data.Entities.User", b => @@ -465,10 +463,6 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<bool>("DisplayMissingEpisodes") .HasColumnType("INTEGER"); - b.Property<string>("EasyPassword") - .HasMaxLength(65535) - .HasColumnType("TEXT"); - b.Property<bool>("EnableAutoLogin") .HasColumnType("INTEGER"); @@ -554,7 +548,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("Username") .IsUnique(); - b.ToTable("Users", "jellyfin"); + b.ToTable("Users"); }); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index 63d3e8a04c..700e639700 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; -using EFCoreSecondLevelCacheInterceptor; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 9601954671..cefbd0624d 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -114,8 +114,6 @@ namespace Jellyfin.Server.Implementations.Users await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); } - user.EasyPassword = pin; - return new ForgotPasswordResult { Action = ForgotPasswordAction.PinCode, diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index dc9d78857e..1d03baa4c6 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -269,36 +269,15 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc/> - public Task ResetEasyPassword(User user) - { - return ChangeEasyPassword(user, string.Empty, null); - } - - /// <inheritdoc/> public async Task ChangePassword(User user, string newPassword) { ArgumentNullException.ThrowIfNull(user); - - await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false); - await UpdateUserAsync(user).ConfigureAwait(false); - - await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false); - } - - /// <inheritdoc/> - public async Task ChangeEasyPassword(User user, string newPassword, string? newPasswordSha1) - { - if (newPassword is not null) - { - newPasswordSha1 = _cryptoProvider.CreatePasswordHash(newPassword).ToString(); - } - - if (string.IsNullOrWhiteSpace(newPasswordSha1)) + if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) { - throw new ArgumentNullException(nameof(newPasswordSha1)); + throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); } - user.EasyPassword = newPasswordSha1; + await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false); await UpdateUserAsync(user).ConfigureAwait(false); await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false); @@ -315,7 +294,6 @@ namespace Jellyfin.Server.Implementations.Users ServerId = _appHost.SystemId, HasPassword = hasPassword, HasConfiguredPassword = hasPassword, - HasConfiguredEasyPassword = !string.IsNullOrEmpty(user.EasyPassword), EnableAutoLogin = user.EnableAutoLogin, LastLoginDate = user.LastLoginDate, LastActivityDate = user.LastActivityDate, @@ -369,8 +347,10 @@ namespace Jellyfin.Server.Implementations.Users EnablePlaybackRemuxing = user.HasPermission(PermissionKind.EnablePlaybackRemuxing), ForceRemoteSourceTranscoding = user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding), EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing), + EnableCollectionManagement = user.HasPermission(PermissionKind.EnableCollectionManagement), AccessSchedules = user.AccessSchedules.ToArray(), BlockedTags = user.GetPreference(PreferenceKind.BlockedTags), + AllowedTags = user.GetPreference(PreferenceKind.AllowedTags), EnabledChannels = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels), EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices), EnabledFolders = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders), @@ -684,6 +664,7 @@ namespace Jellyfin.Server.Implementations.Users 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.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); @@ -696,6 +677,7 @@ namespace Jellyfin.Server.Implementations.Users // 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); @@ -736,7 +718,7 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name)); } - private static bool IsValidUsername(string name) + private static bool IsValidUsername(ReadOnlySpan<char> name) { // 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 @@ -828,16 +810,6 @@ namespace Jellyfin.Server.Implementations.Users } } - if (!success - && _networkManager.IsInLocalNetwork(remoteEndPoint) - && user?.EnableLocalPassword == true - && !string.IsNullOrEmpty(user.EasyPassword)) - { - // Check easy password - var passwordHash = PasswordHash.Parse(user.EasyPassword); - success = _cryptoProvider.Verify(passwordHash, password); - } - return (authenticationProvider, username, success); } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 40cd5a0446..939376dd8d 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -22,7 +22,6 @@ using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Activity; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index e9af1cf83c..9867c9e47a 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -5,19 +5,15 @@ using System.Linq; using System.Net; using System.Net.Sockets; using System.Reflection; +using System.Security.Claims; using Emby.Server.Implementations; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.AnonymousLanAccessPolicy; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; -using Jellyfin.Api.Auth.DownloadPolicy; -using Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy; -using Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy; -using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; -using Jellyfin.Api.Auth.IgnoreParentalControlPolicy; +using Jellyfin.Api.Auth.FirstTimeSetupPolicy; using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy; -using Jellyfin.Api.Auth.LocalAccessPolicy; -using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Auth.SyncPlayAccessPolicy; +using Jellyfin.Api.Auth.UserPermissionPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Api.Formatters; @@ -56,117 +52,38 @@ namespace Jellyfin.Server.Extensions /// <returns>The updated service collection.</returns> public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection) { + // The default handler must be first so that it is evaluated first serviceCollection.AddSingleton<IAuthorizationHandler, DefaultAuthorizationHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, DownloadHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupOrDefaultHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupOrElevatedHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, IgnoreParentalControlHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeOrIgnoreParentalControlSetupHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, UserPermissionHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, AnonymousLanAccessHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>(); - serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, SyncPlayAccessHandler>(); + return serviceCollection.AddAuthorizationCore(options => { - options.AddPolicy( - Policies.DefaultAuthorization, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new DefaultAuthorizationRequirement()); - }); - options.AddPolicy( - Policies.Download, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new DownloadRequirement()); - }); - options.AddPolicy( - Policies.FirstTimeSetupOrDefault, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new FirstTimeSetupOrDefaultRequirement()); - }); - options.AddPolicy( - Policies.FirstTimeSetupOrElevated, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new FirstTimeSetupOrElevatedRequirement()); - }); - options.AddPolicy( - Policies.IgnoreParentalControl, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new IgnoreParentalControlRequirement()); - }); - options.AddPolicy( - Policies.FirstTimeSetupOrIgnoreParentalControl, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new FirstTimeOrIgnoreParentalControlSetupRequirement()); - }); - options.AddPolicy( - Policies.LocalAccessOnly, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new LocalAccessRequirement()); - }); - options.AddPolicy( - Policies.LocalAccessOrRequiresElevation, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new LocalAccessOrRequiresElevationRequirement()); - }); + options.DefaultPolicy = new AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication) + .AddRequirements(new DefaultAuthorizationRequirement()) + .Build(); + + options.AddPolicy(Policies.AnonymousLanAccessPolicy, new AnonymousLanAccessRequirement()); + options.AddPolicy(Policies.CollectionManagement, new UserPermissionRequirement(PermissionKind.EnableCollectionManagement)); + options.AddPolicy(Policies.Download, new UserPermissionRequirement(PermissionKind.EnableContentDownloading)); + options.AddPolicy(Policies.FirstTimeSetupOrDefault, new FirstTimeSetupRequirement(requireAdmin: false)); + options.AddPolicy(Policies.FirstTimeSetupOrElevated, new FirstTimeSetupRequirement()); + options.AddPolicy(Policies.FirstTimeSetupOrIgnoreParentalControl, new FirstTimeSetupRequirement(false, false)); + options.AddPolicy(Policies.IgnoreParentalControl, new DefaultAuthorizationRequirement(validateParentalSchedule: false)); + options.AddPolicy(Policies.LiveTvAccess, new UserPermissionRequirement(PermissionKind.EnableLiveTvAccess)); + options.AddPolicy(Policies.LiveTvManagement, new UserPermissionRequirement(PermissionKind.EnableLiveTvManagement)); + options.AddPolicy(Policies.LocalAccessOrRequiresElevation, new LocalAccessOrRequiresElevationRequirement()); + options.AddPolicy(Policies.SyncPlayHasAccess, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.HasAccess)); + options.AddPolicy(Policies.SyncPlayCreateGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.CreateGroup)); + options.AddPolicy(Policies.SyncPlayJoinGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup)); + options.AddPolicy(Policies.SyncPlayIsInGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup)); options.AddPolicy( Policies.RequiresElevation, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new RequiresElevationRequirement()); - }); - options.AddPolicy( - Policies.SyncPlayHasAccess, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.HasAccess)); - }); - options.AddPolicy( - Policies.SyncPlayCreateGroup, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.CreateGroup)); - }); - options.AddPolicy( - Policies.SyncPlayJoinGroup, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup)); - }); - options.AddPolicy( - Policies.SyncPlayIsInGroup, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup)); - }); - options.AddPolicy( - Policies.AnonymousLanAccessPolicy, - policy => - { - policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new AnonymousLanAccessRequirement()); - }); + policy => policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication) + .RequireClaim(ClaimTypes.Role, UserRoles.Administrator)); }); } @@ -334,6 +251,14 @@ namespace Jellyfin.Server.Extensions }); } + private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement) + { + authorizationOptions.AddPolicy(policyName, policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication).AddRequirements(authorizationRequirement); + }); + } + /// <summary> /// Sets up the proxy configuration based on the addresses in <paramref name="allowedProxies"/>. /// </summary> diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs index 645696e319..bf38f741cd 100644 --- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs +++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs @@ -1,12 +1,16 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; +using System.Reflection; using Jellyfin.Extensions; using Jellyfin.Server.Migrations; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; +using MediaBrowser.Controller.Net.WebSocketMessages.Outbound; using MediaBrowser.Model.ApiClient; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; using Microsoft.OpenApi.Any; @@ -36,17 +40,141 @@ namespace Jellyfin.Server.Filters /// <inheritdoc /> public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) { - context.SchemaGenerator.GenerateSchema(typeof(LibraryUpdateInfo), context.SchemaRepository); context.SchemaGenerator.GenerateSchema(typeof(IPlugin), context.SchemaRepository); - context.SchemaGenerator.GenerateSchema(typeof(PlayRequest), context.SchemaRepository); - context.SchemaGenerator.GenerateSchema(typeof(PlaystateRequest), context.SchemaRepository); - context.SchemaGenerator.GenerateSchema(typeof(TimerEventInfo), context.SchemaRepository); - context.SchemaGenerator.GenerateSchema(typeof(SendCommand), context.SchemaRepository); - context.SchemaGenerator.GenerateSchema(typeof(GeneralCommandType), context.SchemaRepository); - context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<object>), context.SchemaRepository); + var webSocketTypes = typeof(WebSocketMessage).Assembly.GetTypes() + .Where(t => t.IsSubclassOf(typeof(WebSocketMessage)) + && !t.IsGenericType + && t != typeof(WebSocketMessageInfo)) + .ToList(); + + var inboundWebSocketSchemas = new List<OpenApiSchema>(); + var inboundWebSocketDiscriminators = new Dictionary<string, string>(); + foreach (var type in webSocketTypes.Where(t => typeof(IInboundWebSocketMessage).IsAssignableFrom(t))) + { + var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value; + if (messageType is null) + { + continue; + } + + var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); + inboundWebSocketSchemas.Add(schema); + inboundWebSocketDiscriminators[messageType.ToString()!] = schema.Reference.ReferenceV3; + } + + var inboundWebSocketMessageSchema = new OpenApiSchema + { + Type = "object", + Description = "Represents the list of possible inbound websocket types", + Reference = new OpenApiReference + { + Id = nameof(InboundWebSocketMessage), + Type = ReferenceType.Schema + }, + OneOf = inboundWebSocketSchemas, + Discriminator = new OpenApiDiscriminator + { + PropertyName = nameof(WebSocketMessage.MessageType), + Mapping = inboundWebSocketDiscriminators + } + }; + + context.SchemaRepository.AddDefinition(nameof(InboundWebSocketMessage), inboundWebSocketMessageSchema); + + var outboundWebSocketSchemas = new List<OpenApiSchema>(); + var outboundWebSocketDiscriminators = new Dictionary<string, string>(); + foreach (var type in webSocketTypes.Where(t => typeof(IOutboundWebSocketMessage).IsAssignableFrom(t))) + { + var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value; + if (messageType is null) + { + continue; + } + + // Additional discriminator needed for GroupUpdate models... + if (messageType == SessionMessageType.SyncPlayGroupUpdate && type != typeof(SyncPlayGroupUpdateCommandMessage)) + { + continue; + } + + var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); + outboundWebSocketSchemas.Add(schema); + outboundWebSocketDiscriminators.Add(messageType.ToString()!, schema.Reference.ReferenceV3); + } + + var outboundWebSocketMessageSchema = new OpenApiSchema + { + Type = "object", + Description = "Represents the list of possible outbound websocket types", + Reference = new OpenApiReference + { + Id = nameof(OutboundWebSocketMessage), + Type = ReferenceType.Schema + }, + OneOf = outboundWebSocketSchemas, + Discriminator = new OpenApiDiscriminator + { + PropertyName = nameof(WebSocketMessage.MessageType), + Mapping = outboundWebSocketDiscriminators + } + }; + + context.SchemaRepository.AddDefinition(nameof(OutboundWebSocketMessage), outboundWebSocketMessageSchema); + context.SchemaRepository.AddDefinition( + nameof(WebSocketMessage), + new OpenApiSchema + { + Type = "object", + Description = "Represents the possible websocket types", + Reference = new OpenApiReference + { + Id = nameof(WebSocketMessage), + Type = ReferenceType.Schema + }, + OneOf = new[] + { + inboundWebSocketMessageSchema, + outboundWebSocketMessageSchema + } + }); + + // Manually generate sync play GroupUpdate messages. + if (!context.SchemaRepository.Schemas.TryGetValue(nameof(GroupUpdate), out var groupUpdateSchema)) + { + groupUpdateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository); + } + + var groupUpdateOfGroupInfoSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<GroupInfoDto>), context.SchemaRepository); + var groupUpdateOfGroupStateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<GroupStateUpdate>), context.SchemaRepository); + var groupUpdateOfStringSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<string>), context.SchemaRepository); + var groupUpdateOfPlayQueueSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<PlayQueueUpdate>), context.SchemaRepository); + + groupUpdateSchema.OneOf = new List<OpenApiSchema> + { + groupUpdateOfGroupInfoSchema, + groupUpdateOfGroupStateSchema, + groupUpdateOfStringSchema, + groupUpdateOfPlayQueueSchema + }; + + groupUpdateSchema.Discriminator = new OpenApiDiscriminator + { + PropertyName = nameof(GroupUpdate.Type), + Mapping = new Dictionary<string, string> + { + { GroupUpdateType.UserJoined.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, + { GroupUpdateType.UserLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, + { GroupUpdateType.GroupJoined.ToString(), groupUpdateOfGroupInfoSchema.Reference.ReferenceV3 }, + { GroupUpdateType.GroupLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, + { GroupUpdateType.StateUpdate.ToString(), groupUpdateOfGroupStateSchema.Reference.ReferenceV3 }, + { GroupUpdateType.PlayQueue.ToString(), groupUpdateOfPlayQueueSchema.Reference.ReferenceV3 }, + { GroupUpdateType.NotInGroup.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, + { GroupUpdateType.GroupDoesNotExist.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }, + { GroupUpdateType.LibraryAccessDenied.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 } + } + }; - context.SchemaGenerator.GenerateSchema(typeof(SessionMessageType), context.SchemaRepository); context.SchemaGenerator.GenerateSchema(typeof(ServerDiscoveryInfo), context.SchemaRepository); foreach (var configuration in _serverConfigurationManager.GetConfigurationStores()) diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs index 4af670e9a0..fb9f6d0a6e 100644 --- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs +++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs @@ -18,11 +18,17 @@ namespace Jellyfin.Server.Filters { var requiredScopes = new List<string>(); + var requiresAuth = false; // Add all method scopes. foreach (var attribute in context.MethodInfo.GetCustomAttributes(true)) { - if (attribute is AuthorizeAttribute authorizeAttribute - && authorizeAttribute.Policy is not null + if (attribute is not AuthorizeAttribute authorizeAttribute) + { + continue; + } + + requiresAuth = true; + if (authorizeAttribute.Policy is not null && !requiredScopes.Contains(authorizeAttribute.Policy, StringComparer.Ordinal)) { requiredScopes.Add(authorizeAttribute.Policy); @@ -35,8 +41,13 @@ namespace Jellyfin.Server.Filters { foreach (var attribute in controllerAttributes) { - if (attribute is AuthorizeAttribute authorizeAttribute - && authorizeAttribute.Policy is not null + if (attribute is not AuthorizeAttribute authorizeAttribute) + { + continue; + } + + requiresAuth = true; + if (authorizeAttribute.Policy is not null && !requiredScopes.Contains(authorizeAttribute.Policy, StringComparer.Ordinal)) { requiredScopes.Add(authorizeAttribute.Policy); @@ -44,35 +55,37 @@ namespace Jellyfin.Server.Filters } } - if (requiredScopes.Count != 0) + if (!requiresAuth) { - if (!operation.Responses.ContainsKey("401")) - { - operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); - } + return; + } - if (!operation.Responses.ContainsKey("403")) - { - operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" }); - } + if (!operation.Responses.ContainsKey("401")) + { + operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); + } - var scheme = new OpenApiSecurityScheme + if (!operation.Responses.ContainsKey("403")) + { + operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" }); + } + + var scheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = AuthenticationSchemes.CustomAuthentication - } - }; + Type = ReferenceType.SecurityScheme, + Id = AuthenticationSchemes.CustomAuthentication + } + }; - operation.Security = new List<OpenApiSecurityRequirement> + operation.Security = new List<OpenApiSecurityRequirement> + { + new OpenApiSecurityRequirement { - new OpenApiSecurityRequirement - { - [scheme] = requiredScopes - } - }; - } + [scheme] = requiredScopes + } + }; } } } diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs index f1bb9b2831..fda6e54656 100644 --- a/Jellyfin.Server/Helpers/StartupHelpers.cs +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Net; +using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; using System.Threading.Tasks; @@ -22,6 +25,43 @@ namespace Jellyfin.Server.Helpers; /// </summary> public static class StartupHelpers { + private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; + + /// <summary> + /// Logs relevant environment variables and information about the host. + /// </summary> + /// <param name="logger">The logger to use.</param> + /// <param name="appPaths">The application paths to use.</param> + public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths) + { + // Distinct these to prevent users from reporting problems that aren't actually problems + var commandLineArgs = Environment + .GetCommandLineArgs() + .Distinct(); + + // Get all relevant environment variables + var allEnvVars = Environment.GetEnvironmentVariables(); + var relevantEnvVars = new Dictionary<object, object>(); + foreach (var key in allEnvVars.Keys) + { + if (_relevantEnvVarPrefixes.Any(prefix => key.ToString()!.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + { + relevantEnvVars.Add(key, allEnvVars[key]!); + } + } + + logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); + logger.LogInformation("Arguments: {Args}", commandLineArgs); + logger.LogInformation("Operating system: {OS}", RuntimeInformation.OSDescription); + logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture); + logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess); + logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive); + logger.LogInformation("Processor count: {ProcessorCount}", Environment.ProcessorCount); + logger.LogInformation("Program data path: {ProgramDataPath}", appPaths.ProgramDataPath); + logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath); + logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); + } + /// <summary> /// Create the data, config and log paths from the variety of inputs(command line args, /// environment variables) or decide on what default to use. For Windows it's %AppPath% @@ -33,137 +73,55 @@ public static class StartupHelpers /// <returns><see cref="ServerApplicationPaths" />.</returns> public static ServerApplicationPaths CreateApplicationPaths(StartupOptions options) { - // dataDir - // IF --datadir - // ELSE IF $JELLYFIN_DATA_DIR - // ELSE IF windows, use <%APPDATA%>/jellyfin - // ELSE IF $XDG_DATA_HOME then use $XDG_DATA_HOME/jellyfin - // ELSE use $HOME/.local/share/jellyfin - var dataDir = options.DataDir; - if (string.IsNullOrEmpty(dataDir)) - { - dataDir = Environment.GetEnvironmentVariable("JELLYFIN_DATA_DIR"); + // LocalApplicationData + // Windows: %LocalAppData% + // macOS: NSApplicationSupportDirectory + // UNIX: $XDG_DATA_HOME + var dataDir = options.DataDir + ?? Environment.GetEnvironmentVariable("JELLYFIN_DATA_DIR") + ?? Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "jellyfin"); - if (string.IsNullOrEmpty(dataDir)) + var configDir = options.ConfigDir ?? Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR"); + if (configDir is null) + { + configDir = Path.Join(dataDir, "config"); + if (options.DataDir is null + && !Directory.Exists(configDir) + && !OperatingSystem.IsWindows() + && !OperatingSystem.IsMacOS()) { - // LocalApplicationData follows the XDG spec on unix machines - dataDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + // UNIX: $XDG_CONFIG_HOME + configDir = Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin"); } } - // configDir - // IF --configdir - // ELSE IF $JELLYFIN_CONFIG_DIR - // ELSE IF --datadir, use <datadir>/config (assume portable run) - // ELSE IF <datadir>/config exists, use that - // ELSE IF windows, use <datadir>/config - // ELSE IF $XDG_CONFIG_HOME use $XDG_CONFIG_HOME/jellyfin - // ELSE $HOME/.config/jellyfin - var configDir = options.ConfigDir; - if (string.IsNullOrEmpty(configDir)) + var cacheDir = options.CacheDir ?? Environment.GetEnvironmentVariable("JELLYFIN_CACHE_DIR"); + if (cacheDir is null) { - configDir = Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR"); - - if (string.IsNullOrEmpty(configDir)) + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) { - if (options.DataDir is not null - || Directory.Exists(Path.Combine(dataDir, "config")) - || OperatingSystem.IsWindows()) - { - // Hang config folder off already set dataDir - configDir = Path.Combine(dataDir, "config"); - } - else - { - // $XDG_CONFIG_HOME defines the base directory relative to which - // user specific configuration files should be stored. - configDir = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); - - // If $XDG_CONFIG_HOME is either not set or empty, - // a default equal to $HOME /.config should be used. - if (string.IsNullOrEmpty(configDir)) - { - configDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config"); - } - - configDir = Path.Combine(configDir, "jellyfin"); - } + cacheDir = Path.Join(dataDir, "cache"); } - } - - // cacheDir - // IF --cachedir - // ELSE IF $JELLYFIN_CACHE_DIR - // ELSE IF windows, use <datadir>/cache - // ELSE IF XDG_CACHE_HOME, use $XDG_CACHE_HOME/jellyfin - // ELSE HOME/.cache/jellyfin - var cacheDir = options.CacheDir; - if (string.IsNullOrEmpty(cacheDir)) - { - cacheDir = Environment.GetEnvironmentVariable("JELLYFIN_CACHE_DIR"); - - if (string.IsNullOrEmpty(cacheDir)) + else { - if (OperatingSystem.IsWindows()) - { - // Hang cache folder off already set dataDir - cacheDir = Path.Combine(dataDir, "cache"); - } - else - { - // $XDG_CACHE_HOME defines the base directory relative to which - // user specific non-essential data files should be stored. - cacheDir = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); - - // If $XDG_CACHE_HOME is either not set or empty, - // a default equal to $HOME/.cache should be used. - if (string.IsNullOrEmpty(cacheDir)) - { - cacheDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cache"); - } - - cacheDir = Path.Combine(cacheDir, "jellyfin"); - } + cacheDir = Path.Join(GetXdgCacheHome(), "jellyfin"); } } - // webDir - // IF --webdir - // ELSE IF $JELLYFIN_WEB_DIR - // ELSE <bindir>/jellyfin-web - var webDir = options.WebDir; - if (string.IsNullOrEmpty(webDir)) + var webDir = options.WebDir ?? Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR"); + if (webDir is null) { - webDir = Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR"); - - if (string.IsNullOrEmpty(webDir)) - { - // Use default location under ResourcesPath - webDir = Path.Combine(AppContext.BaseDirectory, "jellyfin-web"); - } + webDir = Path.Join(AppContext.BaseDirectory, "jellyfin-web"); } - // logDir - // IF --logdir - // ELSE IF $JELLYFIN_LOG_DIR - // ELSE IF --datadir, use <datadir>/log (assume portable run) - // ELSE <datadir>/log - var logDir = options.LogDir; - if (string.IsNullOrEmpty(logDir)) + var logDir = options.LogDir ?? Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR"); + if (logDir is null) { - logDir = Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR"); - - if (string.IsNullOrEmpty(logDir)) - { - // Hang log folder off already set dataDir - logDir = Path.Combine(dataDir, "log"); - } + logDir = Path.Join(dataDir, "log"); } // Normalize paths. Only possible with GetFullPath for now - https://github.com/dotnet/runtime/issues/2162 @@ -191,6 +149,24 @@ public static class StartupHelpers return new ServerApplicationPaths(dataDir, logDir, configDir, cacheDir, webDir); } + private static string GetXdgCacheHome() + { + // $XDG_CACHE_HOME defines the base directory relative to which + // user specific non-essential data files should be stored. + var cacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + + // If $XDG_CACHE_HOME is either not set or a relative path, + // a default equal to $HOME/.cache should be used. + if (cacheHome is null || !cacheHome.StartsWith('/')) + { + cacheHome = Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cache"); + } + + return cacheHome; + } + /// <summary> /// Gets the path for the unix socket Kestrel should bind to. /// </summary> @@ -203,16 +179,17 @@ public static class StartupHelpers if (string.IsNullOrEmpty(socketPath)) { + const string SocketFile = "jellyfin.sock"; + var xdgRuntimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); - var socketFile = "jellyfin.sock"; if (xdgRuntimeDir is null) { // Fall back to config dir - socketPath = Path.Join(appPaths.ConfigurationDirectoryPath, socketFile); + socketPath = Path.Join(appPaths.ConfigurationDirectoryPath, SocketFile); } else { - socketPath = Path.Join(xdgRuntimeDir, socketFile); + socketPath = Path.Join(xdgRuntimeDir, SocketFile); } } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 9ea8508f24..146de3ae13 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -24,31 +24,31 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> - <PackageReference Include="CommandLineParser" Version="2.9.1" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.2" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.2" /> - <PackageReference Include="prometheus-net" Version="7.0.0" /> - <PackageReference Include="prometheus-net.AspNetCore" Version="7.0.0" /> - <PackageReference Include="Serilog.AspNetCore" Version="6.1.0" /> - <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> - <PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" /> - <PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" /> - <PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" /> - <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> - <PackageReference Include="Serilog.Sinks.Graylog" Version="2.3.0" /> - <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.4" /> + <PackageReference Include="CommandLineParser" /> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" /> + <PackageReference Include="prometheus-net" /> + <PackageReference Include="prometheus-net.AspNetCore" /> + <PackageReference Include="Serilog.AspNetCore" /> + <PackageReference Include="Serilog.Enrichers.Thread" /> + <PackageReference Include="Serilog.Settings.Configuration" /> + <PackageReference Include="Serilog.Sinks.Async" /> + <PackageReference Include="Serilog.Sinks.Console" /> + <PackageReference Include="Serilog.Sinks.File" /> + <PackageReference Include="Serilog.Sinks.Graylog" /> + <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 23fb9e3708..abfdcd77d5 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -21,7 +21,8 @@ namespace Jellyfin.Server.Migrations /// </summary> private static readonly Type[] _preStartupMigrationTypes = { - typeof(PreStartupRoutines.CreateNetworkConfiguration) + typeof(PreStartupRoutines.CreateNetworkConfiguration), + typeof(PreStartupRoutines.MigrateMusicBrainzTimeout) }; /// <summary> @@ -38,7 +39,9 @@ namespace Jellyfin.Server.Migrations typeof(Routines.ReaddDefaultPluginRepository), typeof(Routines.MigrateDisplayPreferencesDb), typeof(Routines.RemoveDownloadImagesInAdvance), - typeof(Routines.MigrateAuthenticationDb) + typeof(Routines.MigrateAuthenticationDb), + typeof(Routines.FixPlaylistOwner), + typeof(Routines.MigrateRatingLevels) }; /// <summary> diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs new file mode 100644 index 0000000000..bee135efda --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; +using Emby.Server.Implementations; +using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.PreStartupRoutines; + +/// <inheritdoc /> +public class MigrateMusicBrainzTimeout : IMigrationRoutine +{ + private readonly ServerApplicationPaths _applicationPaths; + private readonly ILogger<MigrateMusicBrainzTimeout> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="MigrateMusicBrainzTimeout"/> class. + /// </summary> + /// <param name="applicationPaths">An instance of <see cref="ServerApplicationPaths"/>.</param> + /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param> + public MigrateMusicBrainzTimeout(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory) + { + _applicationPaths = applicationPaths; + _logger = loggerFactory.CreateLogger<MigrateMusicBrainzTimeout>(); + } + + /// <inheritdoc /> + public Guid Id => Guid.Parse("A6DCACF4-C057-4Ef9-80D3-61CEF9DDB4F0"); + + /// <inheritdoc /> + public string Name => nameof(MigrateMusicBrainzTimeout); + + /// <inheritdoc /> + public bool PerformOnNewInstall => false; + + /// <inheritdoc /> + public void Perform() + { + string path = Path.Combine(_applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.MusicBrainz.xml"); + if (!File.Exists(path)) + { + _logger.LogDebug("No MusicBrainz plugin configuration file found, skipping"); + return; + } + + var oldPluginConfiguration = ReadOld(path); + + if (oldPluginConfiguration is not null) + { + var newPluginConfiguration = new PluginConfiguration(); + newPluginConfiguration.Server = oldPluginConfiguration.Server; + newPluginConfiguration.ReplaceArtistName = oldPluginConfiguration.ReplaceArtistName; + var newRateLimit = oldPluginConfiguration.RateLimit / 1000.0; + newPluginConfiguration.RateLimit = newRateLimit < 1.0 ? 1.0 : newRateLimit; + WriteNew(path, newPluginConfiguration); + } + } + + private OldMusicBrainzConfiguration? ReadOld(string path) + { + using (var xmlReader = XmlReader.Create(path)) + { + var serverConfigSerializer = new XmlSerializer(typeof(OldMusicBrainzConfiguration), new XmlRootAttribute("PluginConfiguration")); + return serverConfigSerializer.Deserialize(xmlReader) as OldMusicBrainzConfiguration; + } + } + + private void WriteNew(string path, PluginConfiguration newPluginConfiguration) + { + var pluginConfigurationSerializer = new XmlSerializer(typeof(PluginConfiguration), new XmlRootAttribute("PluginConfiguration")); + var xmlWriterSettings = new XmlWriterSettings { Indent = true }; + using (var xmlWriter = XmlWriter.Create(path, xmlWriterSettings)) + { + pluginConfigurationSerializer.Serialize(xmlWriter, newPluginConfiguration); + } + } + +#pragma warning disable + public sealed class OldMusicBrainzConfiguration + { + private string _server = string.Empty; + + private long _rateLimit = 0L; + + public string Server + { + get => _server; + set => _server = value.TrimEnd('/'); + } + + public long RateLimit + { + get => _rateLimit; + set => _rateLimit = value; + } + + public bool ReplaceArtistName { get; set; } + } +#pragma warning restore + +} diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs new file mode 100644 index 0000000000..cf31820034 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using System.Threading; + +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Properly set playlist owner. +/// </summary> +internal class FixPlaylistOwner : IMigrationRoutine +{ + private readonly ILogger<RemoveDuplicateExtras> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IPlaylistManager _playlistManager; + + public FixPlaylistOwner( + ILogger<RemoveDuplicateExtras> logger, + ILibraryManager libraryManager, + IPlaylistManager playlistManager) + { + _logger = logger; + _libraryManager = libraryManager; + _playlistManager = playlistManager; + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("{615DFA9E-2497-4DBB-A472-61938B752C5B}"); + + /// <inheritdoc/> + public string Name => "FixPlaylistOwner"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; + + /// <inheritdoc/> + public void Perform() + { + var playlists = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Playlist } + }) + .Cast<Playlist>() + .Where(x => x.OwnerUserId.Equals(Guid.Empty)) + .ToArray(); + + if (playlists.Length > 0) + { + foreach (var playlist in playlists) + { + var shares = playlist.Shares; + if (shares.Length > 0) + { + var firstEditShare = shares.First(x => x.CanEdit); + if (firstEditShare is not null && Guid.TryParse(firstEditShare.UserId, out var guid)) + { + playlist.OwnerUserId = guid; + playlist.Shares = shares.Where(x => x != firstEditShare).ToArray(); + playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + _playlistManager.SavePlaylistFile(playlist); + } + } + else + { + playlist.OpenAccess = true; + playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 4b692d14f0..8fe2b087d9 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -130,12 +130,10 @@ namespace Jellyfin.Server.Migrations.Routines SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length) && int.TryParse(length, out var skipForwardLength) ? skipForwardLength : 30000, - SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) && !string.IsNullOrEmpty(length) && int.TryParse(length, out var skipBackwardLength) + SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) && int.TryParse(length, out var skipBackwardLength) ? skipBackwardLength : 10000, - EnableNextVideoInfoOverlay = dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled) && !string.IsNullOrEmpty(enabled) - ? bool.Parse(enabled) - : true, + EnableNextVideoInfoOverlay = !dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled) || string.IsNullOrEmpty(enabled) || bool.Parse(enabled), DashboardTheme = dto.CustomPrefs.TryGetValue("dashboardtheme", out var theme) ? theme : string.Empty, TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty }; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs new file mode 100644 index 0000000000..9dee520a50 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -0,0 +1,103 @@ +using System; +using System.Globalization; +using System.IO; + +using Emby.Server.Implementations.Data; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Globalization; +using Microsoft.Extensions.Logging; +using SQLitePCL.pretty; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// <summary> + /// Migrate rating levels to new rating level system. + /// </summary> + internal class MigrateRatingLevels : IMigrationRoutine + { + private const string DbFilename = "library.db"; + private readonly ILogger<MigrateRatingLevels> _logger; + private readonly IServerApplicationPaths _applicationPaths; + private readonly ILocalizationManager _localizationManager; + private readonly IItemRepository _repository; + + public MigrateRatingLevels( + IServerApplicationPaths applicationPaths, + ILoggerFactory loggerFactory, + ILocalizationManager localizationManager, + IItemRepository repository) + { + _applicationPaths = applicationPaths; + _localizationManager = localizationManager; + _repository = repository; + _logger = loggerFactory.CreateLogger<MigrateRatingLevels>(); + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("{67445D54-B895-4B24-9F4C-35CE0690EA07}"); + + /// <inheritdoc/> + public string Name => "MigrateRatingLevels"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; + + /// <inheritdoc/> + public void Perform() + { + var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); + + // Back up the database before modifying any entries + for (int i = 1; ; i++) + { + var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i); + if (!File.Exists(bakPath)) + { + try + { + File.Copy(dbPath, bakPath); + _logger.LogInformation("Library database backed up to {BackupPath}", bakPath); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath); + throw; + } + } + } + + // Migrate parental rating strings to new levels + _logger.LogInformation("Recalculating parental rating levels based on rating string."); + using (var connection = SQLite3.Open( + dbPath, + ConnectionFlags.ReadWrite, + null)) + { + var queryResult = connection.Query("SELECT DISTINCT OfficialRating FROM TypedBaseItems"); + foreach (var entry in queryResult) + { + var ratingString = entry[0].ToString(); + if (string.IsNullOrEmpty(ratingString)) + { + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating IS NULL OR OfficialRating='';"); + } + else + { + var ratingValue = _localizationManager.GetRatingLevel(ratingString).ToString(); + if (string.IsNullOrEmpty(ratingValue)) + { + ratingValue = "NULL"; + } + + var statement = connection.PrepareStatement("UPDATE TypedBaseItems SET InheritedParentalRatingValue = @Value WHERE OfficialRating = @Rating;"); + statement.TryBind("@Value", ratingValue); + statement.TryBind("@Rating", ratingString); + statement.ExecuteQuery(); + } + } + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index ea2f033027..0186500a12 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -127,7 +127,6 @@ namespace Jellyfin.Server.Migrations.Routines RememberSubtitleSelections = config.RememberSubtitleSelections, SubtitleLanguagePreference = config.SubtitleLanguagePreference, Password = mockup.Password, - EasyPassword = mockup.EasyPassword, LastLoginDate = mockup.LastLoginDate, LastActivityDate = mockup.LastActivityDate }; @@ -163,6 +162,7 @@ namespace Jellyfin.Server.Migrations.Routines user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); foreach (var policyAccessSchedule in policy.AccessSchedules) { diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 25fe30a392..6e8b17a737 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -148,7 +148,7 @@ namespace Jellyfin.Server "Jellyfin version: {Version}", Assembly.GetEntryAssembly()!.GetName().Version!.ToString(3)); - ApplicationHost.LogEnvironmentInfo(_logger, appPaths); + StartupHelpers.LogEnvironmentInfo(_logger, appPaths); // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 155f9fc8c1..6394800f75 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -4,11 +4,11 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; -using System.Runtime.InteropServices; using System.Text; using Jellyfin.Api.Middleware; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations; @@ -27,6 +27,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; +using Microsoft.VisualBasic; using Prometheus; namespace Jellyfin.Server @@ -79,6 +80,13 @@ namespace Jellyfin.Server var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0); var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9); var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8); + Func<IServiceProvider, HttpMessageHandler> eyeballsHttpClientHandlerDelegate = (_) => new SocketsHttpHandler() + { + AutomaticDecompression = DecompressionMethods.All, + RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8, + ConnectCallback = HttpClientExtension.OnConnect + }; + Func<IServiceProvider, HttpMessageHandler> defaultHttpClientHandlerDelegate = (_) => new SocketsHttpHandler() { AutomaticDecompression = DecompressionMethods.All, @@ -92,7 +100,7 @@ namespace Jellyfin.Server c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader); c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader); }) - .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); + .ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate); services.AddHttpClient(NamedClient.MusicBrainz, c => { @@ -101,6 +109,15 @@ namespace Jellyfin.Server c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader); c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader); }) + .ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate); + + services.AddHttpClient(NamedClient.DirectIp, c => + { + c.DefaultRequestHeaders.UserAgent.Add(productHeader); + c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader); + c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader); + c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader); + }) .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); services.AddHttpClient(NamedClient.Dlna, c => @@ -165,7 +182,7 @@ namespace Jellyfin.Server // This must be injected before any path related middleware. mainApp.UsePathTrim(); - mainApp.UseStaticFiles(); + if (appConfig.HostWebClient()) { var extensionProvider = new FileExtensionContentTypeProvider(); @@ -173,6 +190,11 @@ namespace Jellyfin.Server // subtitles octopus requires .data, .mem files. extensionProvider.Mappings.Add(".data", MediaTypeNames.Application.Octet); extensionProvider.Mappings.Add(".mem", MediaTypeNames.Application.Octet); + mainApp.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath), + RequestPath = "/web" + }); mainApp.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath), @@ -183,6 +205,7 @@ namespace Jellyfin.Server mainApp.UseRobotsRedirection(); } + mainApp.UseStaticFiles(); mainApp.UseAuthentication(); mainApp.UseJellyfinApiSwagger(_serverConfigurationManager); mainApp.UseQueryStringDecoding(); diff --git a/Jellyfin.sln.DotSettings b/Jellyfin.sln.DotSettings new file mode 100644 index 0000000000..2ef037485c --- /dev/null +++ b/Jellyfin.sln.DotSettings @@ -0,0 +1,4 @@ +<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Emby/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Jellyfin/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Playstate/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
\ No newline at end of file diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 1b0ff27d98..3f1a098e45 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -19,9 +19,9 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" /> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" /> </ItemGroup> <ItemGroup> @@ -49,13 +49,13 @@ <!-- Code analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs index 7cf1b8aa0b..ec76a43b6f 100644 --- a/MediaBrowser.Common/Net/IPHost.cs +++ b/MediaBrowser.Common/Net/IPHost.cs @@ -190,7 +190,7 @@ namespace MediaBrowser.Common.Net /// <returns>Object representing the string, if it has successfully been parsed.</returns> public static IPHost Parse(string host) { - if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res)) + if (IPHost.TryParse(host, out IPHost res)) { return res; } @@ -206,7 +206,7 @@ namespace MediaBrowser.Common.Net /// <returns>Object representing the string, if it has successfully been parsed.</returns> public static IPHost Parse(string host, AddressFamily family) { - if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res)) + if (IPHost.TryParse(host, out IPHost res)) { if (family == AddressFamily.InterNetwork) { diff --git a/MediaBrowser.Common/Net/IPNetAddress.cs b/MediaBrowser.Common/Net/IPNetAddress.cs index ac3396a9f1..de72d978ec 100644 --- a/MediaBrowser.Common/Net/IPNetAddress.cs +++ b/MediaBrowser.Common/Net/IPNetAddress.cs @@ -167,6 +167,11 @@ namespace MediaBrowser.Common.Net address = address.MapToIPv4(); } + if (address.AddressFamily != AddressFamily) + { + return false; + } + var (altAddress, altPrefix) = NetworkAddressOf(address, PrefixLength); return NetworkAddress.Address.Equals(altAddress) && NetworkAddress.PrefixLength >= altPrefix; } diff --git a/MediaBrowser.Common/Net/NamedClient.cs b/MediaBrowser.Common/Net/NamedClient.cs index a6cacd4f17..9c5544b0ff 100644 --- a/MediaBrowser.Common/Net/NamedClient.cs +++ b/MediaBrowser.Common/Net/NamedClient.cs @@ -1,4 +1,4 @@ -namespace MediaBrowser.Common.Net +namespace MediaBrowser.Common.Net { /// <summary> /// Registered http client names. @@ -6,7 +6,7 @@ public static class NamedClient { /// <summary> - /// Gets the value for the default named http client. + /// Gets the value for the default named http client which implements happy eyeballs. /// </summary> public const string Default = nameof(Default); @@ -19,5 +19,10 @@ /// Gets the value for the DLNA named http client. /// </summary> public const string Dlna = nameof(Dlna); + + /// <summary> + /// Non happy eyeballs implementation. + /// </summary> + public const string DirectIp = nameof(DirectIp); } } diff --git a/MediaBrowser.Common/Plugins/BasePluginOfT.cs b/MediaBrowser.Common/Plugins/BasePluginOfT.cs index 152fa8b4a2..116e9cef80 100644 --- a/MediaBrowser.Common/Plugins/BasePluginOfT.cs +++ b/MediaBrowser.Common/Plugins/BasePluginOfT.cs @@ -50,7 +50,7 @@ namespace MediaBrowser.Common.Plugins if (Version is not null && !Directory.Exists(dataFolderPath)) { // Try again with the version number appended to the folder name. - dataFolderPath += "_" + Version.ToString(); + dataFolderPath += "_" + Version; } SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version); diff --git a/MediaBrowser.Common/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs index fa92d383a2..1d73de3c95 100644 --- a/MediaBrowser.Common/Plugins/IPluginManager.cs +++ b/MediaBrowser.Common/Plugins/IPluginManager.cs @@ -57,7 +57,7 @@ namespace MediaBrowser.Common.Plugins /// <param name="path">The path where to save the manifest.</param> /// <param name="status">Initial status of the plugin.</param> /// <returns>True if successful.</returns> - Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status); + Task<bool> PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status); /// <summary> /// Imports plugin details from a folder. diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs index 2910dbe144..e0847ccea4 100644 --- a/MediaBrowser.Common/Plugins/PluginManifest.cs +++ b/MediaBrowser.Common/Plugins/PluginManifest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using MediaBrowser.Model.Plugins; @@ -23,6 +24,7 @@ namespace MediaBrowser.Common.Plugins Overview = string.Empty; TargetAbi = string.Empty; Version = string.Empty; + Assemblies = Array.Empty<string>(); } /// <summary> @@ -104,5 +106,12 @@ namespace MediaBrowser.Common.Plugins /// </summary> [JsonPropertyName("imagePath")] public string? ImagePath { get; set; } + + /// <summary> + /// Gets or sets the collection of assemblies that should be loaded. + /// Paths are considered relative to the plugin folder. + /// </summary> + [JsonPropertyName("assemblies")] + public IReadOnlyList<string> Assemblies { get; set; } } } diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs index ed7c2c2c17..b263c173eb 100644 --- a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -1,11 +1,10 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Threading; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; namespace MediaBrowser.Controller.BaseItemManager @@ -15,8 +14,6 @@ namespace MediaBrowser.Controller.BaseItemManager { private readonly IServerConfigurationManager _serverConfigurationManager; - private int _metadataRefreshConcurrency; - /// <summary> /// Initializes a new instance of the <see cref="BaseItemManager"/> class. /// </summary> @@ -24,17 +21,9 @@ namespace MediaBrowser.Controller.BaseItemManager public BaseItemManager(IServerConfigurationManager serverConfigurationManager) { _serverConfigurationManager = serverConfigurationManager; - - _metadataRefreshConcurrency = GetMetadataRefreshConcurrency(); - SetupMetadataThrottler(); - - _serverConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; } /// <inheritdoc /> - public SemaphoreSlim MetadataRefreshThrottler { get; private set; } - - /// <inheritdoc /> public bool IsMetadataFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name) { if (baseItem is Channel) @@ -51,12 +40,11 @@ namespace MediaBrowser.Controller.BaseItemManager if (libraryTypeOptions is not null) { - return libraryTypeOptions.MetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + return libraryTypeOptions.MetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); } - var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase)); - - return itemConfig is null || !itemConfig.DisabledMetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + var itemConfig = _serverConfigurationManager.GetMetadataOptionsForType(baseItem.GetType().Name); + return itemConfig is null || !itemConfig.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); } /// <inheritdoc /> @@ -76,50 +64,11 @@ namespace MediaBrowser.Controller.BaseItemManager if (libraryTypeOptions is not null) { - return libraryTypeOptions.ImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase)); - - return itemConfig is null || !itemConfig.DisabledImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - /// <summary> - /// Called when the configuration is updated. - /// It will refresh the metadata throttler if the relevant config changed. - /// </summary> - private void OnConfigurationUpdated(object? sender, EventArgs e) - { - int newMetadataRefreshConcurrency = GetMetadataRefreshConcurrency(); - if (_metadataRefreshConcurrency != newMetadataRefreshConcurrency) - { - _metadataRefreshConcurrency = newMetadataRefreshConcurrency; - SetupMetadataThrottler(); - } - } - - /// <summary> - /// Creates the metadata refresh throttler. - /// </summary> - [MemberNotNull(nameof(MetadataRefreshThrottler))] - private void SetupMetadataThrottler() - { - MetadataRefreshThrottler = new SemaphoreSlim(_metadataRefreshConcurrency); - } - - /// <summary> - /// Returns the metadata refresh concurrency. - /// </summary> - private int GetMetadataRefreshConcurrency() - { - var concurrency = _serverConfigurationManager.Configuration.LibraryMetadataRefreshConcurrency; - - if (concurrency <= 0) - { - concurrency = Environment.ProcessorCount; + return libraryTypeOptions.ImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); } - return concurrency; + var itemConfig = _serverConfigurationManager.GetMetadataOptionsForType(baseItem.GetType().Name); + return itemConfig is null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); } } } diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs index b07c80879d..ac20120d97 100644 --- a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs @@ -10,11 +10,6 @@ namespace MediaBrowser.Controller.BaseItemManager public interface IBaseItemManager { /// <summary> - /// Gets the semaphore used to limit the amount of concurrent metadata refreshes. - /// </summary> - SemaphoreSlim MetadataRefreshThrottler { get; } - - /// <summary> /// Is metadata fetcher enabled. /// </summary> /// <param name="baseItem">The base item.</param> diff --git a/MediaBrowser.Controller/Channels/IChannelManager.cs b/MediaBrowser.Controller/Channels/IChannelManager.cs index 49be897ef3..8eb27888ab 100644 --- a/MediaBrowser.Controller/Channels/IChannelManager.cs +++ b/MediaBrowser.Controller/Channels/IChannelManager.cs @@ -16,12 +16,6 @@ namespace MediaBrowser.Controller.Channels public interface IChannelManager { /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="channels">The channels.</param> - void AddParts(IEnumerable<IChannel> channels); - - /// <summary> /// Gets the channel features. /// </summary> /// <param name="id">The identifier.</param> @@ -52,14 +46,14 @@ namespace MediaBrowser.Controller.Channels /// </summary> /// <param name="query">The query.</param> /// <returns>The channels.</returns> - QueryResult<Channel> GetChannelsInternal(ChannelQuery query); + Task<QueryResult<Channel>> GetChannelsInternalAsync(ChannelQuery query); /// <summary> /// Gets the channels. /// </summary> /// <param name="query">The query.</param> /// <returns>The channels.</returns> - QueryResult<BaseItemDto> GetChannels(ChannelQuery query); + Task<QueryResult<BaseItemDto>> GetChannelsAsync(ChannelQuery query); /// <summary> /// Gets the latest channel items. diff --git a/MediaBrowser.Controller/Collections/ICollectionManager.cs b/MediaBrowser.Controller/Collections/ICollectionManager.cs index b8c33ee5a0..38a78a67b5 100644 --- a/MediaBrowser.Controller/Collections/ICollectionManager.cs +++ b/MediaBrowser.Controller/Collections/ICollectionManager.cs @@ -56,5 +56,12 @@ namespace MediaBrowser.Controller.Collections /// <param name="user">The user.</param> /// <returns>IEnumerable{BaseItem}.</returns> IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user); + + /// <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> + /// <returns>The folder instance referencing the collection storage.</returns> + Task<Folder?> GetCollectionsFolder(bool createIfNeeded); } } diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index 89aafc84fb..22453f0f76 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CA1002 using System.Collections.Generic; @@ -28,7 +27,7 @@ namespace MediaBrowser.Controller.Dto /// <param name="user">The user.</param> /// <param name="owner">The owner.</param> /// <returns>BaseItemDto.</returns> - BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User user = null, BaseItem owner = null); + BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null); /// <summary> /// Gets the base item dtos. @@ -38,7 +37,7 @@ namespace MediaBrowser.Controller.Dto /// <param name="user">The user.</param> /// <param name="owner">The owner.</param> /// <returns>The <see cref="IReadOnlyList{T}"/> of <see cref="BaseItemDto"/>.</returns> - IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null); + IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null); /// <summary> /// Gets the item by name dto. @@ -48,6 +47,6 @@ namespace MediaBrowser.Controller.Dto /// <param name="taggedItems">The list of tagged items.</param> /// <param name="user">The user.</param> /// <returns>The item dto.</returns> - BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem> taggedItems, User user = null); + BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem>? taggedItems, User? user = null); } } diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs index 08c622cde0..d789033f16 100644 --- a/MediaBrowser.Controller/Entities/AggregateFolder.cs +++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs @@ -120,7 +120,7 @@ namespace MediaBrowser.Controller.Entities var path = ContainingFolderPath; - var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService) + var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, LibraryManager) { FileInfo = FileSystem.GetDirectoryInfo(path) }; diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index 15a79fa1fc..18d948a62f 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -59,7 +59,7 @@ namespace MediaBrowser.Controller.Entities.Audio { if (IsAccessedByName) { - return new List<BaseItem>(); + return Enumerable.Empty<BaseItem>(); } return base.Children; diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index f2c2007f7a..5018110035 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities /// The supported image extensions. /// </summary> public static readonly string[] SupportedImageExtensions - = new[] { ".png", ".jpg", ".jpeg", ".tbn", ".gif" }; + = new[] { ".png", ".jpg", ".jpeg", ".webp", ".tbn", ".gif" }; private static readonly List<string> _supportedExtensions = new List<string>(SupportedImageExtensions) { @@ -129,6 +129,13 @@ namespace MediaBrowser.Controller.Entities public string Album { get; set; } /// <summary> + /// Gets or sets the LUFS value. + /// </summary> + /// <value>The LUFS Value.</value> + [JsonIgnore] + public float LUFS { get; set; } + + /// <summary> /// Gets or sets the channel identifier. /// </summary> /// <value>The channel identifier.</value> @@ -554,7 +561,7 @@ namespace MediaBrowser.Controller.Entities public string OfficialRating { get; set; } [JsonIgnore] - public int InheritedParentalRatingValue { get; set; } + public int? InheritedParentalRatingValue { get; set; } /// <summary> /// Gets or sets the critic rating. @@ -801,16 +808,14 @@ namespace MediaBrowser.Controller.Entities { return allowed.Contains(ChannelId); } - else - { - var collectionFolders = LibraryManager.GetCollectionFolders(this, allCollectionFolders); - foreach (var folder in collectionFolders) + var collectionFolders = LibraryManager.GetCollectionFolders(this, allCollectionFolders); + + foreach (var folder in collectionFolders) + { + if (allowed.Contains(folder.Id)) { - if (allowed.Contains(folder.Id)) - { - return true; - } + return true; } } @@ -893,16 +898,6 @@ namespace MediaBrowser.Controller.Entities var sortable = Name.Trim().ToLowerInvariant(); - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) - { - sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); - } - - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) - { - sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); - } - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) { // Remove from beginning if a space follows @@ -921,12 +916,22 @@ namespace MediaBrowser.Controller.Entities } } + foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + { + sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); + } + + foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + { + sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); + } + return ModifySortChunks(sortable); } - internal static string ModifySortChunks(string name) + internal static string ModifySortChunks(ReadOnlySpan<char> name) { - void AppendChunk(StringBuilder builder, bool isDigitChunk, ReadOnlySpan<char> chunk) + static void AppendChunk(StringBuilder builder, bool isDigitChunk, ReadOnlySpan<char> chunk) { if (isDigitChunk && chunk.Length < 10) { @@ -936,7 +941,7 @@ namespace MediaBrowser.Controller.Entities builder.Append(chunk); } - if (name.Length == 0) + if (name.IsEmpty) { return string.Empty; } @@ -950,13 +955,13 @@ namespace MediaBrowser.Controller.Entities var isDigit = char.IsDigit(name[i]); if (isDigit != isDigitChunk) { - AppendChunk(builder, isDigitChunk, name.AsSpan(chunkStart, i - chunkStart)); + AppendChunk(builder, isDigitChunk, name.Slice(chunkStart, i - chunkStart)); chunkStart = i; isDigitChunk = isDigit; } } - AppendChunk(builder, isDigitChunk, name.AsSpan(chunkStart)); + AppendChunk(builder, isDigitChunk, name.Slice(chunkStart)); // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString()); return builder.ToString().RemoveDiacritics(); @@ -1239,14 +1244,6 @@ namespace MediaBrowser.Controller.Entities return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken); } - protected virtual void TriggerOnRefreshStart() - { - } - - protected virtual void TriggerOnRefreshComplete() - { - } - /// <summary> /// Overrides the base implementation to refresh metadata for local trailers. /// </summary> @@ -1255,8 +1252,6 @@ namespace MediaBrowser.Controller.Entities /// <returns>true if a provider reports we changed.</returns> public async Task<ItemUpdateType> RefreshMetadata(MetadataRefreshOptions options, CancellationToken cancellationToken) { - TriggerOnRefreshStart(); - var requiresSave = false; if (SupportsOwnedItems) @@ -1276,21 +1271,14 @@ namespace MediaBrowser.Controller.Entities } } - try - { - var refreshOptions = requiresSave - ? new MetadataRefreshOptions(options) - { - ForceSave = true - } - : options; + var refreshOptions = requiresSave + ? new MetadataRefreshOptions(options) + { + ForceSave = true + } + : options; - return await ProviderManager.RefreshSingleItem(this, refreshOptions, cancellationToken).ConfigureAwait(false); - } - finally - { - TriggerOnRefreshComplete(); - } + return await ProviderManager.RefreshSingleItem(this, refreshOptions, cancellationToken).ConfigureAwait(false); } protected bool IsVisibleStandaloneInternal(User user, bool checkFolders) @@ -1362,7 +1350,7 @@ namespace MediaBrowser.Controller.Entities private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); - var newExtraIds = extras.Select(i => i.Id).ToArray(); + var newExtraIds = Array.ConvertAll(extras, x => x.Id); var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds); if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) @@ -1534,12 +1522,6 @@ namespace MediaBrowser.Controller.Entities } var maxAllowedRating = user.MaxParentalAgeRating; - - if (maxAllowedRating is null) - { - return true; - } - var rating = CustomRatingForComparison; if (string.IsNullOrEmpty(rating)) @@ -1549,12 +1531,13 @@ namespace MediaBrowser.Controller.Entities if (string.IsNullOrEmpty(rating)) { + Logger.LogDebug("{0} has no parental rating set.", Name); return !GetBlockUnratedValue(user); } var value = LocalizationManager.GetRatingLevel(rating); - // Could not determine the integer value + // Could not determine rating level if (!value.HasValue) { var isAllowed = !GetBlockUnratedValue(user); @@ -1567,7 +1550,7 @@ namespace MediaBrowser.Controller.Entities return isAllowed; } - return value.Value <= maxAllowedRating.Value; + return !maxAllowedRating.HasValue || value.Value <= maxAllowedRating.Value; } public int? GetInheritedParentalRatingValue() @@ -1607,6 +1590,12 @@ namespace MediaBrowser.Controller.Entities return false; } + var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags); + if (allowedTagsPreference.Any() && !allowedTagsPreference.Any(i => Tags.Contains(i, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + return true; } @@ -1621,10 +1610,10 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Gets the block unrated value. + /// Gets a bool indicating if access to the unrated item is blocked or not. /// </summary> /// <param name="user">The configuration.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> + /// <returns><c>true</c> if blocked, <c>false</c> otherwise.</returns> protected virtual bool GetBlockUnratedValue(User user) { // Don't block plain folders that are unrated. Let the media underneath get blocked @@ -2511,7 +2500,7 @@ namespace MediaBrowser.Controller.Entities var item = this; - var inheritedParentalRatingValue = item.GetInheritedParentalRatingValue() ?? 0; + var inheritedParentalRatingValue = item.GetInheritedParentalRatingValue() ?? null; if (inheritedParentalRatingValue != item.InheritedParentalRatingValue) { item.InheritedParentalRatingValue = inheritedParentalRatingValue; diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index 5ac619d8f5..095b261c05 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -288,7 +288,7 @@ namespace MediaBrowser.Controller.Entities { var path = ContainingFolderPath; - var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService) + var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, LibraryManager) { FileInfo = FileSystem.GetDirectoryInfo(path), Parent = GetParent() as Folder, diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index bccb4107ff..44fe65103e 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -301,14 +301,6 @@ namespace MediaBrowser.Controller.Entities return dictionary; } - protected override void TriggerOnRefreshStart() - { - } - - protected override void TriggerOnRefreshComplete() - { - } - /// <summary> /// Validates the children internal. /// </summary> @@ -510,26 +502,17 @@ namespace MediaBrowser.Controller.Entities private async Task RefreshAllMetadataForContainer(IMetadataContainer container, MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { - // limit the amount of concurrent metadata refreshes - await ProviderManager.RunMetadataRefresh( - async () => - { - var series = container as Series; - if (series is not null) - { - await series.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false); - } + if (container is Series series) + { + await series.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false); + } - await container.RefreshAllMetadata(refreshOptions, progress, cancellationToken).ConfigureAwait(false); - }, - cancellationToken).ConfigureAwait(false); + await container.RefreshAllMetadata(refreshOptions, progress, cancellationToken).ConfigureAwait(false); } private async Task RefreshChildMetadata(BaseItem child, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken) { - var container = child as IMetadataContainer; - - if (container is not null) + if (child is IMetadataContainer container) { await RefreshAllMetadataForContainer(container, refreshOptions, progress, cancellationToken).ConfigureAwait(false); } @@ -537,10 +520,7 @@ namespace MediaBrowser.Controller.Entities { if (refreshOptions.RefreshItem(child)) { - // limit the amount of concurrent metadata refreshes - await ProviderManager.RunMetadataRefresh( - async () => await child.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false), - cancellationToken).ConfigureAwait(false); + await child.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false); } if (recursive && child is Folder folder) @@ -586,7 +566,7 @@ namespace MediaBrowser.Controller.Entities } var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency; - var parallelism = fanoutConcurrency == 0 ? Environment.ProcessorCount : fanoutConcurrency; + var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : 2 * Environment.ProcessorCount; var actionBlock = new ActionBlock<int>( async i => @@ -618,7 +598,7 @@ namespace MediaBrowser.Controller.Entities for (var i = 0; i < childrenCount; i++) { - actionBlock.Post(i); + await actionBlock.SendAsync(i).ConfigureAwait(false); } actionBlock.Complete(); @@ -730,7 +710,7 @@ namespace MediaBrowser.Controller.Entities return LibraryManager.GetItemsResult(query); } - private QueryResult<BaseItem> QueryWithPostFiltering2(InternalItemsQuery query) + protected QueryResult<BaseItem> QueryWithPostFiltering2(InternalItemsQuery query) { var startIndex = query.StartIndex; var limit = query.Limit; @@ -1272,7 +1252,7 @@ namespace MediaBrowser.Controller.Entities { ArgumentNullException.ThrowIfNull(user); - return GetChildren(user, includeLinkedChildren, null); + return GetChildren(user, includeLinkedChildren, new InternalItemsQuery(user)); } public virtual List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) diff --git a/MediaBrowser.Controller/Entities/IHasShares.cs b/MediaBrowser.Controller/Entities/IHasShares.cs deleted file mode 100644 index e6fa27703b..0000000000 --- a/MediaBrowser.Controller/Entities/IHasShares.cs +++ /dev/null @@ -1,11 +0,0 @@ -#nullable disable - -#pragma warning disable CA1819, CS1591 - -namespace MediaBrowser.Controller.Entities -{ - public interface IHasShares - { - Share[] Shares { get; set; } - } -} diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index a1e5319048..a51299284b 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -26,6 +26,7 @@ namespace MediaBrowser.Controller.Entities EnableTotalRecordCount = true; ExcludeArtistIds = Array.Empty<Guid>(); ExcludeInheritedTags = Array.Empty<string>(); + IncludeInheritedTags = Array.Empty<string>(); ExcludeItemIds = Array.Empty<Guid>(); ExcludeItemTypes = Array.Empty<BaseItemKind>(); ExcludeTags = Array.Empty<string>(); @@ -95,6 +96,8 @@ namespace MediaBrowser.Controller.Entities public string[] ExcludeInheritedTags { get; set; } + public string[] IncludeInheritedTags { get; set; } + public IReadOnlyList<string> Genres { get; set; } public bool? IsSpecialSeason { get; set; } @@ -368,6 +371,7 @@ namespace MediaBrowser.Controller.Entities } ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags); + IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags); User = user; } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 882abc9272..66210cb6c4 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -104,7 +104,7 @@ namespace MediaBrowser.Controller.Entities.Movies public override bool IsAuthorizedToDelete(User user, List<Folder> allCollectionFolders) { - return true; + return user.HasPermission(PermissionKind.IsAdministrator) || user.HasPermission(PermissionKind.EnableCollectionManagement); } public override bool IsSaveLocalMetadataEnabled() diff --git a/MediaBrowser.Controller/Entities/PeopleHelper.cs b/MediaBrowser.Controller/Entities/PeopleHelper.cs index 7f8dc069cf..5292bd7727 100644 --- a/MediaBrowser.Controller/Entities/PeopleHelper.cs +++ b/MediaBrowser.Controller/Entities/PeopleHelper.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Entities @@ -17,38 +18,38 @@ namespace MediaBrowser.Controller.Entities // Normalize if (string.Equals(person.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase)) { - person.Type = PersonType.GuestStar; + person.Type = PersonKind.GuestStar; } else if (string.Equals(person.Role, PersonType.Director, StringComparison.OrdinalIgnoreCase)) { - person.Type = PersonType.Director; + person.Type = PersonKind.Director; } else if (string.Equals(person.Role, PersonType.Producer, StringComparison.OrdinalIgnoreCase)) { - person.Type = PersonType.Producer; + person.Type = PersonKind.Producer; } else if (string.Equals(person.Role, PersonType.Writer, StringComparison.OrdinalIgnoreCase)) { - person.Type = PersonType.Writer; + person.Type = PersonKind.Writer; } // If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes - if (string.Equals(person.Type, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase)) + if (person.Type == PersonKind.GuestStar) { - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type.Equals(PersonType.Actor, StringComparison.OrdinalIgnoreCase)); + var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor); if (existing is not null) { - existing.Type = PersonType.GuestStar; + existing.Type = PersonKind.GuestStar; MergeExisting(existing, person); return; } } - if (string.Equals(person.Type, PersonType.Actor, StringComparison.OrdinalIgnoreCase)) + if (person.Type == PersonKind.Actor) { // If the actor already exists without a role and we have one, fill it in - var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type.Equals(PersonType.Actor, StringComparison.OrdinalIgnoreCase) || p.Type.Equals(PersonType.GuestStar, StringComparison.OrdinalIgnoreCase))); + var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar)); if (existing is null) { // Wasn't there - add it @@ -68,8 +69,8 @@ namespace MediaBrowser.Controller.Entities else { var existing = people.FirstOrDefault(p => - string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase) && - string.Equals(p.Type, person.Type, StringComparison.OrdinalIgnoreCase)); + string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase) + && p.Type == person.Type); // Check for dupes based on the combination of Name and Type if (existing is null) diff --git a/MediaBrowser.Controller/Entities/PersonInfo.cs b/MediaBrowser.Controller/Entities/PersonInfo.cs index 2b689ae7e2..3df0b0b785 100644 --- a/MediaBrowser.Controller/Entities/PersonInfo.cs +++ b/MediaBrowser.Controller/Entities/PersonInfo.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Entities @@ -36,7 +37,7 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the type. /// </summary> /// <value>The type.</value> - public string Type { get; set; } + public PersonKind Type { get; set; } /// <summary> /// Gets or sets the ascending sort order. @@ -57,10 +58,6 @@ namespace MediaBrowser.Controller.Entities return Name; } - public bool IsType(string type) - { - return string.Equals(Type, type, StringComparison.OrdinalIgnoreCase) - || string.Equals(Role, type, StringComparison.OrdinalIgnoreCase); - } + public bool IsType(PersonKind type) => Type == type || string.Equals(type.ToString(), Role, StringComparison.OrdinalIgnoreCase); } } diff --git a/MediaBrowser.Controller/Entities/Share.cs b/MediaBrowser.Controller/Entities/Share.cs deleted file mode 100644 index 64f446eef2..0000000000 --- a/MediaBrowser.Controller/Entities/Share.cs +++ /dev/null @@ -1,13 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller.Entities -{ - public class Share - { - public string UserId { get; set; } - - public bool CanEdit { get; set; } - } -} diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index c83149a6dc..597b4cecbc 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -308,6 +308,11 @@ namespace MediaBrowser.Controller.Entities.TV id.SeriesDisplayOrder = series.DisplayOrder; } + if (Season is not null) + { + id.SeasonProviderIds = Season.ProviderIds; + } + id.IsMissingEpisode = IsMissingEpisode; id.IndexNumberEnd = IndexNumberEnd; diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index e7a8a773ec..a49c1609d9 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -28,6 +28,7 @@ namespace MediaBrowser.Controller.Entities.TV public Series() { AirDays = Array.Empty<DayOfWeek>(); + SeasonNames = new Dictionary<int, string>(); } public DayOfWeek[] AirDays { get; set; } @@ -35,6 +36,9 @@ namespace MediaBrowser.Controller.Entities.TV public string AirTime { get; set; } [JsonIgnore] + public Dictionary<int, string> SeasonNames { get; set; } + + [JsonIgnore] public override bool SupportsAddingToPlaylist => true; [JsonIgnore] diff --git a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs index 3b5e8ece71..6c58064ce9 100644 --- a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs +++ b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs @@ -60,6 +60,11 @@ namespace MediaBrowser.Controller.Extensions public const string UnixSocketPermissionsKey = "kestrel:socketPermissions"; /// <summary> + /// The cache size of the SQL database, see cache_size. + /// </summary> + public const string SqliteCacheSizeKey = "sqlite:cacheSize"; + + /// <summary> /// Gets a value indicating whether the application should host static web content from the <see cref="IConfiguration"/>. /// </summary> /// <param name="configuration">The configuration to retrieve the value from.</param> @@ -115,5 +120,13 @@ namespace MediaBrowser.Controller.Extensions /// <returns>The unix socket permissions.</returns> public static string? GetUnixSocketPermissions(this IConfiguration configuration) => configuration[UnixSocketPermissionsKey]; + + /// <summary> + /// Gets the cache_size from the <see cref="IConfiguration" />. + /// </summary> + /// <param name="configuration">The configuration to read the setting from.</param> + /// <returns>The sqlite cache size.</returns> + public static int? GetSqliteCacheSize(this IConfiguration configuration) + => configuration.GetValue<int?>(SqliteCacheSizeKey); } } diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 993e3e18f9..6d6a532dba 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -47,14 +45,14 @@ namespace MediaBrowser.Controller.Library /// <param name="id">The id.</param> /// <returns>The user with the specified Id, or <c>null</c> if the user doesn't exist.</returns> /// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception> - User GetUserById(Guid id); + User? GetUserById(Guid id); /// <summary> /// Gets the name of the user by. /// </summary> /// <param name="name">The name.</param> /// <returns>User.</returns> - User GetUserByName(string name); + User? GetUserByName(string name); /// <summary> /// Renames the user. @@ -99,13 +97,6 @@ namespace MediaBrowser.Controller.Library Task ResetPassword(User user); /// <summary> - /// Resets the easy password. - /// </summary> - /// <param name="user">The user.</param> - /// <returns>Task.</returns> - Task ResetEasyPassword(User user); - - /// <summary> /// Changes the password. /// </summary> /// <param name="user">The user.</param> @@ -114,21 +105,12 @@ namespace MediaBrowser.Controller.Library Task ChangePassword(User user, string newPassword); /// <summary> - /// Changes the easy password. - /// </summary> - /// <param name="user">The user.</param> - /// <param name="newPassword">New password to use.</param> - /// <param name="newPasswordSha1">Hash of new password.</param> - /// <returns>Task.</returns> - Task ChangeEasyPassword(User user, string newPassword, string newPasswordSha1); - - /// <summary> /// Gets the user dto. /// </summary> /// <param name="user">The user.</param> /// <param name="remoteEndPoint">The remote end point.</param> /// <returns>UserDto.</returns> - UserDto GetUserDto(User user, string remoteEndPoint = null); + UserDto GetUserDto(User user, string? remoteEndPoint = null); /// <summary> /// Authenticates the user. @@ -139,7 +121,7 @@ namespace MediaBrowser.Controller.Library /// <param name="remoteEndPoint">Remove endpoint to use.</param> /// <param name="isUserSession">Specifies if a user session.</param> /// <returns>User wrapped in awaitable task.</returns> - Task<User> AuthenticateUser(string username, string password, string passwordSha1, string remoteEndPoint, bool isUserSession); + Task<User?> AuthenticateUser(string username, string password, string passwordSha1, string remoteEndPoint, bool isUserSession); /// <summary> /// Starts the forgot password process. diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs index 01986d3032..c701021679 100644 --- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -1,12 +1,11 @@ #nullable disable -#pragma warning disable CA1721, CA1819, CS1591 +#pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Linq; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; @@ -23,22 +22,20 @@ namespace MediaBrowser.Controller.Library /// </summary> private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; private LibraryOptions _libraryOptions; /// <summary> /// Initializes a new instance of the <see cref="ItemResolveArgs" /> class. /// </summary> /// <param name="appPaths">The app paths.</param> - /// <param name="directoryService">The directory service.</param> - public ItemResolveArgs(IServerApplicationPaths appPaths, IDirectoryService directoryService) + /// <param name="libraryManager">The library manager.</param> + public ItemResolveArgs(IServerApplicationPaths appPaths, ILibraryManager libraryManager) { _appPaths = appPaths; - DirectoryService = directoryService; + _libraryManager = libraryManager; } - // TODO remove dependencies as properties, they should be injected where it makes sense - public IDirectoryService DirectoryService { get; } - /// <summary> /// Gets or sets the file system children. /// </summary> @@ -47,7 +44,7 @@ namespace MediaBrowser.Controller.Library public LibraryOptions LibraryOptions { - get => _libraryOptions ??= Parent is null ? new LibraryOptions() : BaseItem.LibraryManager.GetLibraryOptions(Parent); + get => _libraryOptions ??= Parent is null ? new LibraryOptions() : _libraryManager.GetLibraryOptions(Parent); set => _libraryOptions = value; } @@ -231,21 +228,15 @@ namespace MediaBrowser.Controller.Library /// <summary> /// Gets the configured content type for the path. /// </summary> - /// <remarks> - /// This is subject to future refactoring as it relies on a static property in BaseItem. - /// </remarks> /// <returns>The configured content type.</returns> public string GetConfiguredContentType() { - return BaseItem.LibraryManager.GetConfiguredContentType(Path); + return _libraryManager.GetConfiguredContentType(Path); } /// <summary> /// Gets the file system children that do not hit the ignore file check. /// </summary> - /// <remarks> - /// This is subject to future refactoring as it relies on a static property in BaseItem. - /// </remarks> /// <returns>The file system children that are not ignored.</returns> public IEnumerable<FileSystemMetadata> GetActualFileSystemChildren() { @@ -253,7 +244,7 @@ namespace MediaBrowser.Controller.Library for (var i = 0; i < numberOfChildren; i++) { var child = FileSystemChildren[i]; - if (BaseItem.LibraryManager.IgnoreFile(child, Parent)) + if (_libraryManager.IgnoreFile(child, Parent)) { continue; } diff --git a/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs b/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs index 7bc8fa5abd..6d2c3c3d29 100644 --- a/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs +++ b/MediaBrowser.Controller/Library/LibraryManagerExtensions.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -9,7 +7,7 @@ namespace MediaBrowser.Controller.Library { public static class LibraryManagerExtensions { - public static BaseItem GetItemById(this ILibraryManager manager, string id) + public static BaseItem? GetItemById(this ILibraryManager manager, string id) { return manager.GetItemById(new Guid(id)); } diff --git a/MediaBrowser.Controller/Library/MetadataConfigurationExtensions.cs b/MediaBrowser.Controller/Library/MetadataConfigurationExtensions.cs index 41cfcae163..ee9420cb43 100644 --- a/MediaBrowser.Controller/Library/MetadataConfigurationExtensions.cs +++ b/MediaBrowser.Controller/Library/MetadataConfigurationExtensions.cs @@ -1,8 +1,8 @@ -#nullable disable - #pragma warning disable CS1591 +using System; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; namespace MediaBrowser.Controller.Library @@ -10,8 +10,15 @@ namespace MediaBrowser.Controller.Library public static class MetadataConfigurationExtensions { public static MetadataConfiguration GetMetadataConfiguration(this IConfigurationManager config) - { - return config.GetConfiguration<MetadataConfiguration>("metadata"); - } + => config.GetConfiguration<MetadataConfiguration>("metadata"); + + /// <summary> + /// Gets the <see cref="MetadataOptions" /> for the specified type. + /// </summary> + /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param> + /// <param name="type">The type to get the <see cref="MetadataOptions" /> for.</param> + /// <returns>The <see cref="MetadataOptions" /> for the specified type or <c>null</c>.</returns> + public static MetadataOptions? GetMetadataOptionsForType(this IServerConfigurationManager config, string type) + => Array.Find(config.Configuration.MetadataOptions, i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)); } } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 46bdca3027..3b6a16dee3 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -97,7 +97,7 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="query">The query.</param> /// <param name="options">The options.</param> /// <returns>A recording.</returns> - QueryResult<BaseItemDto> GetRecordings(RecordingQuery query, DtoOptions options); + Task<QueryResult<BaseItemDto>> GetRecordingsAsync(RecordingQuery query, DtoOptions options); /// <summary> /// Gets the timers. @@ -308,6 +308,6 @@ namespace MediaBrowser.Controller.LiveTv void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null); - List<BaseItem> GetRecordingFolders(User user); + Task<BaseItem[]> GetRecordingFoldersAsync(User user); } } diff --git a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs index 9788260420..f11e3c8f68 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvChannel.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -105,12 +106,9 @@ namespace MediaBrowser.Controller.LiveTv protected override string CreateSortName() { - if (!string.IsNullOrEmpty(Number)) + if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number)) { - if (double.TryParse(Number, NumberStyles.Any, CultureInfo.InvariantCulture, out double number)) - { - return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty); - } + return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty); } return (Number ?? string.Empty) + "-" + (Name ?? string.Empty); @@ -122,9 +120,7 @@ namespace MediaBrowser.Controller.LiveTv } public IEnumerable<BaseItem> GetTaggedItems() - { - return new List<BaseItem>(); - } + => Enumerable.Empty<BaseItem>(); public override List<MediaSourceInfo> GetMediaSources(bool enablePathSubstitution) { diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index 514323238e..c721fb7785 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -197,10 +197,8 @@ namespace MediaBrowser.Controller.LiveTv { return 2.0 / 3; } - else - { - return 16.0 / 9; - } + + return 16.0 / 9; } public override string GetClientTypeName() diff --git a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs b/MediaBrowser.Controller/Lyrics/LyricMetadata.cs index 6091ede52a..c4f0334892 100644 --- a/MediaBrowser.Controller/Lyrics/LyricMetadata.cs +++ b/MediaBrowser.Controller/Lyrics/LyricMetadata.cs @@ -1,5 +1,3 @@ -using System; - namespace MediaBrowser.Controller.Lyrics; /// <summary> diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 20909c9d57..69c0d26b6b 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -18,10 +18,10 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.2" /> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> - <PackageReference Include="System.Threading.Tasks.Dataflow" Version="7.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" /> + <PackageReference Include="System.Threading.Tasks.Dataflow" /> </ItemGroup> <ItemGroup> @@ -51,13 +51,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 9f7be977ff..c817cdfd95 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -43,6 +43,11 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _maxKerneli915Hang = new Version(6, 1, 3); private readonly Version _minFixedKernel60i915Hang = new Version(6, 0, 18); + private readonly Version _minFFmpegImplictHwaccel = new Version(6, 0); + private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0); + private readonly Version _minFFmpegOclCuTonemapMode = new Version(5, 1, 3); + private readonly Version _minFFmpegSvtAv1Params = new Version(5, 1); + private static readonly string[] _videoProfilesH264 = new[] { "ConstrainedBaseline", @@ -61,6 +66,48 @@ namespace MediaBrowser.Controller.MediaEncoding "Main10" }; + private static readonly string[] _videoProfilesAv1 = new[] + { + "Main", + "High", + "Professional", + }; + + private static readonly HashSet<string> _mp4ContainerNames = new(StringComparer.OrdinalIgnoreCase) + { + "mp4", + "m4a", + "m4p", + "m4b", + "m4r", + "m4v", + }; + + // Set max transcoding channels for encoders that can't handle more than a set amount of channels + // AAC, FLAC, ALAC, libopus, libvorbis encoders all support at least 8 channels + private static readonly Dictionary<string, int> _audioTranscodeChannelLookup = new(StringComparer.OrdinalIgnoreCase) + { + { "wmav2", 2 }, + { "libmp3lame", 2 }, + { "libfdk_aac", 6 }, + { "aac_at", 6 }, + { "ac3", 6 }, + { "eac3", 6 }, + { "dca", 6 }, + { "mlp", 6 }, + { "truehd", 6 }, + }; + + public static readonly string[] LosslessAudioCodecs = new string[] + { + "alac", + "ape", + "flac", + "mlp", + "truehd", + "wavpack" + }; + public EncodingHelper( IApplicationPaths appPaths, IMediaEncoder mediaEncoder, @@ -74,12 +121,15 @@ namespace MediaBrowser.Controller.MediaEncoding } public string GetH264Encoder(EncodingJobInfo state, EncodingOptions encodingOptions) - => GetH264OrH265Encoder("libx264", "h264", state, encodingOptions); + => GetH26xOrAv1Encoder("libx264", "h264", state, encodingOptions); public string GetH265Encoder(EncodingJobInfo state, EncodingOptions encodingOptions) - => GetH264OrH265Encoder("libx265", "hevc", state, encodingOptions); + => GetH26xOrAv1Encoder("libx265", "hevc", state, encodingOptions); - private string GetH264OrH265Encoder(string defaultEncoder, string hwEncoder, EncodingJobInfo state, EncodingOptions encodingOptions) + public string GetAv1Encoder(EncodingJobInfo state, EncodingOptions encodingOptions) + => GetH26xOrAv1Encoder("libsvtav1", "av1", state, encodingOptions); + + private string GetH26xOrAv1Encoder(string defaultEncoder, string hwEncoder, EncodingJobInfo state, EncodingOptions encodingOptions) { // Only use alternative encoders for video files. // When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully @@ -100,14 +150,10 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(hwType) && encodingOptions.EnableHardwareEncoding - && codecMap.ContainsKey(hwType)) + && codecMap.TryGetValue(hwType, out var preferredEncoder) + && _mediaEncoder.SupportsEncoder(preferredEncoder)) { - var preferredEncoder = codecMap[hwType]; - - if (_mediaEncoder.SupportsEncoder(preferredEncoder)) - { - return preferredEncoder; - } + return preferredEncoder; } } @@ -128,7 +174,8 @@ namespace MediaBrowser.Controller.MediaEncoding private bool IsVaapiFullSupported() { - return _mediaEncoder.SupportsHwaccel("vaapi") + return _mediaEncoder.SupportsHwaccel("drm") + && _mediaEncoder.SupportsHwaccel("vaapi") && _mediaEncoder.SupportsFilter("scale_vaapi") && _mediaEncoder.SupportsFilter("deinterlace_vaapi") && _mediaEncoder.SupportsFilter("tonemap_vaapi") @@ -173,8 +220,8 @@ namespace MediaBrowser.Controller.MediaEncoding } if (string.Equals(state.VideoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) - && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) - && string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase)) + && state.VideoStream.VideoRange == VideoRange.HDR + && state.VideoStream.VideoRangeType == VideoRangeType.DOVI) { // Only native SW decoder and HW accelerator can parse dovi rpu. var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; @@ -185,9 +232,9 @@ namespace MediaBrowser.Controller.MediaEncoding return isSwDecoder || isNvdecDecoder || isVaapiDecoder || isD3d11vaDecoder; } - return string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) - && (string.Equals(state.VideoStream.VideoRangeType, "HDR10", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.VideoStream.VideoRangeType, "HLG", StringComparison.OrdinalIgnoreCase)); + return state.VideoStream.VideoRange == VideoRange.HDR + && (state.VideoStream.VideoRangeType == VideoRangeType.HDR10 + || state.VideoStream.VideoRangeType == VideoRangeType.HLG); } private bool IsVulkanHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) @@ -199,7 +246,7 @@ namespace MediaBrowser.Controller.MediaEncoding // libplacebo has partial Dolby Vision to SDR tonemapping support. return options.EnableTonemapping - && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && state.VideoStream.VideoRange == VideoRange.HDR && GetVideoColorBitDepth(state) == 10; } @@ -214,8 +261,8 @@ namespace MediaBrowser.Controller.MediaEncoding // Native VPP tonemapping may come to QSV in the future. - return string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) - && string.Equals(state.VideoStream.VideoRangeType, "HDR10", StringComparison.OrdinalIgnoreCase); + return state.VideoStream.VideoRange == VideoRange.HDR + && state.VideoStream.VideoRangeType == VideoRangeType.HDR10; } /// <summary> @@ -230,6 +277,11 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codec)) { + if (string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase)) + { + return GetAv1Encoder(state, encodingOptions); + } + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { @@ -523,19 +575,28 @@ namespace MediaBrowser.Controller.MediaEncoding { return Array.FindIndex(_videoProfilesH264, x => string.Equals(x, profile, StringComparison.OrdinalIgnoreCase)); } - else if (string.Equals("hevc", videoCodec, StringComparison.OrdinalIgnoreCase)) + + if (string.Equals("hevc", videoCodec, StringComparison.OrdinalIgnoreCase)) { return Array.FindIndex(_videoProfilesH265, x => string.Equals(x, profile, StringComparison.OrdinalIgnoreCase)); } + if (string.Equals("av1", videoCodec, StringComparison.OrdinalIgnoreCase)) + { + return Array.FindIndex(_videoProfilesAv1, x => string.Equals(x, profile, StringComparison.OrdinalIgnoreCase)); + } + return -1; } public string GetInputPathArgument(EncodingJobInfo state) { - var mediaPath = state.MediaPath ?? string.Empty; - - return _mediaEncoder.GetInputArgument(mediaPath, state.MediaSource); + return state.MediaSource.VideoType switch + { + VideoType.Dvd => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistVobFiles(state.MediaPath, null).ToList(), state.MediaSource), + VideoType.BluRay => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistM2tsFiles(state.MediaPath).ToList(), state.MediaSource), + _ => _mediaEncoder.GetInputArgument(state.MediaPath, state.MediaSource) + }; } /// <summary> @@ -549,6 +610,12 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)) { + // Use Apple's aac encoder if available as it provides best audio quality + if (_mediaEncoder.SupportsEncoder("aac_at")) + { + return "aac_at"; + } + // Use libfdk_aac for better audio quality if using custom build of FFmpeg which has fdk_aac support if (_mediaEncoder.SupportsEncoder("libfdk_aac")) { @@ -583,6 +650,11 @@ namespace MediaBrowser.Controller.MediaEncoding return "flac"; } + if (string.Equals(codec, "dts", StringComparison.OrdinalIgnoreCase)) + { + return "dca"; + } + return codec.ToLowerInvariant(); } @@ -608,6 +680,26 @@ namespace MediaBrowser.Controller.MediaEncoding deviceIndex); } + private string GetVulkanDeviceArgs(int deviceIndex, string deviceName, string srcDeviceAlias, string alias) + { + alias ??= VulkanAlias; + deviceIndex = deviceIndex >= 0 + ? deviceIndex + : 0; + var vendorOpts = string.IsNullOrEmpty(deviceName) + ? ":" + deviceIndex + : ":" + "\"" + deviceName + "\""; + var options = string.IsNullOrEmpty(srcDeviceAlias) + ? vendorOpts + : "@" + srcDeviceAlias; + + return string.Format( + CultureInfo.InvariantCulture, + " -init_hw_device vulkan={0}{1}", + alias, + options); + } + private string GetOpenclDeviceArgs(int deviceIndex, string deviceVendorName, string srcDeviceAlias, string alias) { alias ??= OpenclAlias; @@ -643,28 +735,43 @@ namespace MediaBrowser.Controller.MediaEncoding options); } - private string GetVaapiDeviceArgs(string renderNodePath, string driver, string kernelDriver, string alias) + private string GetVaapiDeviceArgs(string renderNodePath, string driver, string kernelDriver, string srcDeviceAlias, string alias) { alias ??= VaapiAlias; renderNodePath = renderNodePath ?? "/dev/dri/renderD128"; - var options = string.IsNullOrEmpty(driver) - ? renderNodePath - : ",driver=" + driver + (string.IsNullOrEmpty(kernelDriver) ? string.Empty : ",kernel_driver=" + kernelDriver); + var driverOpts = string.IsNullOrEmpty(driver) + ? ":" + renderNodePath + : ":,driver=" + driver + (string.IsNullOrEmpty(kernelDriver) ? string.Empty : ",kernel_driver=" + kernelDriver); + var options = string.IsNullOrEmpty(srcDeviceAlias) + ? driverOpts + : "@" + srcDeviceAlias; return string.Format( CultureInfo.InvariantCulture, - " -init_hw_device vaapi={0}:{1}", + " -init_hw_device vaapi={0}{1}", alias, options); } + private string GetDrmDeviceArgs(string renderNodePath, string alias) + { + alias ??= DrmAlias; + renderNodePath = renderNodePath ?? "/dev/dri/renderD128"; + + return string.Format( + CultureInfo.InvariantCulture, + " -init_hw_device drm={0}:{1}", + alias, + renderNodePath); + } + private string GetQsvDeviceArgs(string alias) { var arg = " -init_hw_device qsv=" + (alias ?? QsvAlias); if (OperatingSystem.IsLinux()) { // derive qsv from vaapi device - return GetVaapiDeviceArgs(null, "iHD", "i915", VaapiAlias) + arg + "@" + VaapiAlias; + return GetVaapiDeviceArgs(null, "iHD", "i915", null, VaapiAlias) + arg + "@" + VaapiAlias; } if (OperatingSystem.IsWindows()) @@ -685,9 +792,12 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetGraphicalSubCanvasSize(EncodingJobInfo state) { + // DVBSUB and DVDSUB use the fixed canvas size 720x576 if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode - && !state.SubtitleStream.IsTextSubtitleStream) + && !state.SubtitleStream.IsTextSubtitleStream + && !string.Equals(state.SubtitleStream.Codec, "DVBSUB", StringComparison.OrdinalIgnoreCase) + && !string.Equals(state.SubtitleStream.Codec, "DVDSUB", StringComparison.OrdinalIgnoreCase)) { var inW = state.VideoStream?.Width; var inH = state.VideoStream?.Height; @@ -755,47 +865,57 @@ namespace MediaBrowser.Controller.MediaEncoding if (_mediaEncoder.IsVaapiDeviceInteliHD) { - args.Append(GetVaapiDeviceArgs(null, "iHD", null, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(null, "iHD", null, null, VaapiAlias)); } else if (_mediaEncoder.IsVaapiDeviceInteli965) { // Only override i965 since it has lower priority than iHD in libva lookup. Environment.SetEnvironmentVariable("LIBVA_DRIVER_NAME", "i965"); Environment.SetEnvironmentVariable("LIBVA_DRIVER_NAME_JELLYFIN", "i965"); - args.Append(GetVaapiDeviceArgs(null, "i965", null, VaapiAlias)); - } - else - { - args.Append(GetVaapiDeviceArgs(options.VaapiDevice, null, null, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(null, "i965", null, null, VaapiAlias)); } - var filterDevArgs = GetFilterHwDeviceArgs(VaapiAlias); + var filterDevArgs = string.Empty; + var doOclTonemap = isHwTonemapAvailable && IsOpenclFullSupported(); - if (isHwTonemapAvailable && IsOpenclFullSupported()) + if (_mediaEncoder.IsVaapiDeviceInteliHD || _mediaEncoder.IsVaapiDeviceInteli965) { - if (_mediaEncoder.IsVaapiDeviceInteliHD || _mediaEncoder.IsVaapiDeviceInteli965) + if (doOclTonemap && !isVaapiDecoder) { - if (!isVaapiDecoder) - { - args.Append(GetOpenclDeviceArgs(0, null, VaapiAlias, OpenclAlias)); - filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); - } + args.Append(GetOpenclDeviceArgs(0, null, VaapiAlias, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); } - else if (_mediaEncoder.IsVaapiDeviceAmd) + } + else if (_mediaEncoder.IsVaapiDeviceAmd) + { + if (IsVulkanFullSupported() + && _mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier + && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) { - if (!IsVulkanFullSupported() - || !_mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier - || Environment.OSVersion.Version < _minKernelVersionAmdVkFmtModifier) + args.Append(GetDrmDeviceArgs(options.VaapiDevice, DrmAlias)); + args.Append(GetVaapiDeviceArgs(null, null, null, DrmAlias, VaapiAlias)); + args.Append(GetVulkanDeviceArgs(0, null, DrmAlias, VulkanAlias)); + + // libplacebo wants an explicitly set vulkan filter device. + filterDevArgs = GetFilterHwDeviceArgs(VulkanAlias); + } + else + { + args.Append(GetVaapiDeviceArgs(options.VaapiDevice, null, null, null, VaapiAlias)); + filterDevArgs = GetFilterHwDeviceArgs(VaapiAlias); + + if (doOclTonemap) { + // ROCm/ROCr OpenCL runtime args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias)); filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); } } - else - { - args.Append(GetOpenclDeviceArgs(0, null, null, OpenclAlias)); - filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); - } + } + else if (doOclTonemap) + { + args.Append(GetOpenclDeviceArgs(0, null, null, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); } args.Append(filterDevArgs); @@ -931,8 +1051,18 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(canvasArgs); } - arg.Append(" -i ") - .Append(GetInputPathArgument(state)); + if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) + { + var tmpConcatPath = Path.Join(options.TranscodingTempPath, state.MediaSource.Id + ".concat"); + _mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath); + arg.Append(" -f concat -safe 0 -i ") + .Append(tmpConcatPath); + } + else + { + arg.Append(" -i ") + .Append(GetInputPathArgument(state)); + } // sub2video for external graphical subtitles if (state.SubtitleStream is not null @@ -1026,19 +1156,19 @@ namespace MediaBrowser.Controller.MediaEncoding { return "-bsf:v h264_mp4toannexb"; } - else if (IsH265(stream)) + + if (IsH265(stream)) { return "-bsf:v hevc_mp4toannexb"; } - else if (IsAAC(stream)) + + if (IsAAC(stream)) { // Convert adts header(mpegts) to asc header(mp4). return "-bsf:a aac_adtstoasc"; } - else - { - return null; - } + + return null; } public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) @@ -1095,6 +1225,11 @@ namespace MediaBrowser.Controller.MediaEncoding return FormattableString.Invariant($" -b:v {bitrate}"); } + if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase)) + { + return FormattableString.Invariant($" -b:v {bitrate} -bufsize {bufsize}"); + } + if (string.Equals(videoCodec, "libx264", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "libx265", StringComparison.OrdinalIgnoreCase)) { @@ -1102,24 +1237,24 @@ namespace MediaBrowser.Controller.MediaEncoding } if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase)) { // Override the too high default qmin 18 in transcoding preset return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } if (string.Equals(videoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "av1_vaapi", StringComparison.OrdinalIgnoreCase)) { // VBR in i965 driver may result in pixelated output. if (_mediaEncoder.IsVaapiDeviceInteli965) { return FormattableString.Invariant($" -rc_mode CBR -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } - else - { - return FormattableString.Invariant($" -rc_mode VBR -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); - } + + return FormattableString.Invariant($" -rc_mode VBR -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } return FormattableString.Invariant($" -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); @@ -1127,16 +1262,25 @@ namespace MediaBrowser.Controller.MediaEncoding public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level) { - if (double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out double requestLevel)) + if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel)) { - if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)) + { + // Transcode to level 5.3 (15) and lower for maximum compatibility. + // https://en.wikipedia.org/wiki/AV1#Levels + if (requestLevel < 0 || requestLevel >= 15) + { + return "15"; + } + } + else if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) { // Transcode to level 5.0 and lower for maximum compatibility. // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it. // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880. - if (requestLevel >= 150) + if (requestLevel < 0 || requestLevel >= 150) { return "150"; } @@ -1146,7 +1290,7 @@ namespace MediaBrowser.Controller.MediaEncoding // Transcode to level 5.1 and lower for maximum compatibility. // h264 4k 30fps requires at least level 5.1 otherwise it will break on safari fmp4. // https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels - if (requestLevel >= 51) + if (requestLevel < 0 || requestLevel >= 51) { return "51"; } @@ -1258,22 +1402,11 @@ namespace MediaBrowser.Controller.MediaEncoding { var args = string.Empty; var gopArg = string.Empty; - var keyFrameArg = string.Empty; - if (isEventPlaylist) - { - keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"", - segmentLength); - } - else if (startNumber.HasValue) - { - keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", - startNumber.Value * segmentLength, - segmentLength); - } + + var keyFrameArg = string.Format( + CultureInfo.InvariantCulture, + " -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"", + segmentLength); var framerate = state.VideoStream?.RealFrameRate; if (framerate.HasValue) @@ -1295,14 +1428,18 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + || string.Equals(codec, "av1_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "av1_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "av1_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "libsvtav1", StringComparison.OrdinalIgnoreCase)) { args += gopArg; } else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) + || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "av1_vaapi", StringComparison.OrdinalIgnoreCase)) { args += keyFrameArg; @@ -1333,7 +1470,7 @@ namespace MediaBrowser.Controller.MediaEncoding var param = string.Empty; // Tutorials: Enable Intel GuC / HuC firmware loading for Low Power Encoding. - // https://01.org/linuxgraphics/downloads/firmware + // https://01.org/group/43/downloads/firmware // https://wiki.archlinux.org/title/intel_graphics#Enable_GuC_/_HuC_firmware_loading // Intel Low Power Encoding can save unnecessary CPU-GPU synchronization, // which will reduce overhead in performance intensive tasks such as 4k transcoding and tonemapping. @@ -1438,18 +1575,60 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -crf " + defaultCrf; } } + else if (string.Equals(videoEncoder, "libsvtav1", StringComparison.OrdinalIgnoreCase)) + { + // Default to use the recommended preset 10. + // Omit presets < 5, which are too slow for on the fly encoding. + // https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Ffmpeg.md + param += encodingOptions.EncoderPreset switch + { + "veryslow" => " -preset 5", + "slower" => " -preset 6", + "slow" => " -preset 7", + "medium" => " -preset 8", + "fast" => " -preset 9", + "faster" => " -preset 10", + "veryfast" => " -preset 11", + "superfast" => " -preset 12", + "ultrafast" => " -preset 13", + _ => " -preset 10" + }; + } + else if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "av1_vaapi", StringComparison.OrdinalIgnoreCase)) + { + // -compression_level is not reliable on AMD. + if (_mediaEncoder.IsVaapiDeviceInteliHD) + { + param += encodingOptions.EncoderPreset switch + { + "veryslow" => " -compression_level 1", + "slower" => " -compression_level 2", + "slow" => " -compression_level 3", + "medium" => " -compression_level 4", + "fast" => " -compression_level 5", + "faster" => " -compression_level 6", + "veryfast" => " -compression_level 7", + "superfast" => " -compression_level 7", + "ultrafast" => " -compression_level 7", + _ => string.Empty + }; + } + } else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv) - || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_qsv) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase) // hevc (hevc_qsv) + || string.Equals(videoEncoder, "av1_qsv", StringComparison.OrdinalIgnoreCase)) // av1 (av1_qsv) { - string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" }; + string[] valid_presets = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" }; - if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparison.OrdinalIgnoreCase)) + if (valid_presets.Contains(encodingOptions.EncoderPreset, StringComparison.OrdinalIgnoreCase)) { param += " -preset " + encodingOptions.EncoderPreset; } else { - param += " -preset 7"; + param += " -preset veryfast"; } // Only h264_qsv has look_ahead option @@ -1459,7 +1638,8 @@ namespace MediaBrowser.Controller.MediaEncoding } } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) // hevc (hevc_nvenc) + || string.Equals(videoEncoder, "av1_nvenc", StringComparison.OrdinalIgnoreCase)) // av1 (av1_nvenc) { switch (encodingOptions.EncoderPreset) { @@ -1467,11 +1647,11 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -preset p7"; break; - case "slow": + case "slower": param += " -preset p6"; break; - case "slower": + case "slow": param += " -preset p5"; break; @@ -1499,13 +1679,14 @@ namespace MediaBrowser.Controller.MediaEncoding } } else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) - || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_amf) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) // hevc (hevc_amf) + || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase)) // av1 (av1_amf) { switch (encodingOptions.EncoderPreset) { case "veryslow": - case "slow": case "slower": + case "slow": param += " -quality quality"; break; @@ -1526,9 +1707,15 @@ namespace MediaBrowser.Controller.MediaEncoding break; } + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -header_insertion_mode gop"; + } + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) { - param += " -header_insertion_mode gop -gops_per_idr 1"; + param += " -gops_per_idr 1"; } } else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // vp8 @@ -1659,6 +1846,14 @@ namespace MediaBrowser.Controller.MediaEncoding profile = "high"; } + // We only need Main profile of AV1 encoders. + if (videoEncoder.Contains("av1", StringComparison.OrdinalIgnoreCase) + && (profile.Contains("high", StringComparison.OrdinalIgnoreCase) + || profile.Contains("professional", StringComparison.OrdinalIgnoreCase))) + { + profile = "main"; + } + // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case, // which is compatible (and ugly). if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) @@ -1721,24 +1916,46 @@ namespace MediaBrowser.Controller.MediaEncoding else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) { // hevc_qsv use -level 51 instead of -level 153. - if (double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out double hevcLevel)) + if (double.TryParse(level, CultureInfo.InvariantCulture, out double hevcLevel)) { param += " -level " + (hevcLevel / 3); } } + else if (string.Equals(videoEncoder, "av1_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "libsvtav1", StringComparison.OrdinalIgnoreCase)) + { + // libsvtav1 and av1_qsv use -level 60 instead of -level 16 + // https://aomedia.org/av1/specification/annex-a/ + if (int.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out int av1Level)) + { + var x = 2 + (av1Level >> 2); + var y = av1Level & 3; + var res = (x * 10) + y; + param += " -level " + res; + } + } else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase)) { param += " -level " + level; } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "av1_nvenc", StringComparison.OrdinalIgnoreCase)) { // level option may cause NVENC to fail. // NVENC cannot adjust the given level, just throw an error. + } + else if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "av1_vaapi", StringComparison.OrdinalIgnoreCase)) + { // level option may cause corrupted frames on AMD VAAPI. + if (_mediaEncoder.IsVaapiDeviceInteliHD || _mediaEncoder.IsVaapiDeviceInteli965) + { + param += " -level " + level; + } } else if (!string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) { @@ -1760,6 +1977,12 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -x265-params:0 no-info=1"; } + if (string.Equals(videoEncoder, "libsvtav1", StringComparison.OrdinalIgnoreCase) + && _mediaEncoder.EncoderVersion >= _minFFmpegSvtAv1Params) + { + param += " -svtav1-params:0 rc=1:tune=0:film-grain=0:enable-overlays=1:enable-tf=0"; + } + return param; } @@ -1838,12 +2061,12 @@ namespace MediaBrowser.Controller.MediaEncoding var requestedRangeTypes = state.GetRequestedRangeTypes(videoStream.Codec); if (requestedRangeTypes.Length > 0) { - if (string.IsNullOrEmpty(videoStream.VideoRangeType)) + if (videoStream.VideoRangeType == VideoRangeType.Unknown) { return false; } - if (!requestedRangeTypes.Contains(videoStream.VideoRangeType, StringComparison.OrdinalIgnoreCase)) + if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase)) { return false; } @@ -1900,8 +2123,7 @@ namespace MediaBrowser.Controller.MediaEncoding // If a specific level was requested, the source must match or be less than var level = state.GetRequestedLevel(videoStream.Codec); - if (!string.IsNullOrEmpty(level) - && double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out var requestLevel)) + if (double.TryParse(level, CultureInfo.InvariantCulture, out var requestLevel)) { if (!videoStream.Level.HasValue) { @@ -1978,9 +2200,9 @@ namespace MediaBrowser.Controller.MediaEncoding } } - // Video bitrate must fall within requested value + // Audio bitrate must fall within requested value if (request.AudioBitRate.HasValue - && audioStream.BitDepth.HasValue + && audioStream.BitRate.HasValue && audioStream.BitRate.Value > request.AudioBitRate.Value) { return false; @@ -2044,14 +2266,20 @@ namespace MediaBrowser.Controller.MediaEncoding private static double GetVideoBitrateScaleFactor(string codec) { + // hevc & vp9 - 40% more efficient than h.264 if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase)) + || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) { return .6; } + // av1 - 50% more efficient than h.264 + if (string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase)) + { + return .5; + } + return 1; } @@ -2059,7 +2287,9 @@ namespace MediaBrowser.Controller.MediaEncoding { var inputScaleFactor = GetVideoBitrateScaleFactor(inputVideoCodec); var outputScaleFactor = GetVideoBitrateScaleFactor(outputVideoCodec); - var scaleFactor = outputScaleFactor / inputScaleFactor; + + // Don't scale the real bitrate lower than the requested bitrate + var scaleFactor = Math.Max(outputScaleFactor / inputScaleFactor, 1); if (bitrate <= 500000) { @@ -2081,56 +2311,96 @@ namespace MediaBrowser.Controller.MediaEncoding return Convert.ToInt32(scaleFactor * bitrate); } - public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream) + public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream, int? outputAudioChannels) { - return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream); + return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream, outputAudioChannels); } - public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream) + public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream, int? outputAudioChannels) { if (audioStream is null) { return null; } - if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec)) + var inputChannels = audioStream.Channels ?? 0; + var outputChannels = outputAudioChannels ?? 0; + var bitrate = audioBitRate ?? int.MaxValue; + + if (string.IsNullOrEmpty(audioCodec) + || string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "opus", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "vorbis", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) { - return Math.Min(384000, audioBitRate.Value); + 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), + (_, _) => Math.Min(384000, bitrate) + }; } - if (audioBitRate.HasValue && !string.IsNullOrEmpty(audioCodec)) + if (string.Equals(audioCodec, "dts", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "dca", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase) - || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase) - || string.Equals(audioCodec, "opus", StringComparison.OrdinalIgnoreCase) - || string.Equals(audioCodec, "vorbis", StringComparison.OrdinalIgnoreCase) - || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) - || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + return (inputChannels, outputChannels) switch { - if ((audioStream.Channels ?? 0) >= 6) - { - return Math.Min(640000, audioBitRate.Value); - } + (>= 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) + }; + } - return Math.Min(384000, audioBitRate.Value); - } + // Empty bitrate area is not allow on iOS + // Default audio bitrate to 128K per channel if we don't have codec specific defaults + // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options + return 128000 * (outputAudioChannels ?? audioStream.Channels ?? 2); + } + + public string GetAudioVbrModeParam(string encoder, int bitratePerChannel) + { + if (string.Equals(encoder, "libfdk_aac", StringComparison.OrdinalIgnoreCase)) + { + return " -vbr:a " + bitratePerChannel switch + { + < 32000 => "1", + < 48000 => "2", + < 64000 => "3", + < 96000 => "4", + _ => "5" + }; + } - if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(encoder, "libmp3lame", StringComparison.OrdinalIgnoreCase)) + { + return " -qscale:a " + bitratePerChannel switch { - if ((audioStream.Channels ?? 0) >= 6) - { - return Math.Min(3584000, audioBitRate.Value); - } + < 48000 => "8", + < 64000 => "6", + < 88000 => "4", + < 112000 => "2", + _ => "0" + }; + } - return Math.Min(1536000, audioBitRate.Value); - } + if (string.Equals(encoder, "libvorbis", StringComparison.OrdinalIgnoreCase)) + { + return " -qscale:a " + bitratePerChannel switch + { + < 40000 => "0", + < 56000 => "2", + < 80000 => "4", + < 112000 => "6", + _ => "8" + }; } - // Empty bitrate area is not allow on iOS - // Default audio bitrate to 128K if it is not being requested - // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options - return 128000; + return null; } public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions) @@ -2201,87 +2471,48 @@ namespace MediaBrowser.Controller.MediaEncoding var request = state.BaseRequest; - var inputChannels = audioStream.Channels; + var codec = outputAudioCodec ?? string.Empty; - if (inputChannels <= 0) - { - inputChannels = null; - } + int? resultChannels = state.GetRequestedAudioChannels(codec); - var codec = outputAudioCodec ?? string.Empty; + var inputChannels = audioStream.Channels; - int? transcoderChannelLimit; - if (codec.IndexOf("wma", StringComparison.OrdinalIgnoreCase) != -1) - { - // wmav2 currently only supports two channel output - transcoderChannelLimit = 2; - } - else if (codec.IndexOf("mp3", StringComparison.OrdinalIgnoreCase) != -1) + if (inputChannels > 0) { - // libmp3lame currently only supports two channel output - transcoderChannelLimit = 2; - } - else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1) - { - // aac is able to handle 8ch(7.1 layout) - transcoderChannelLimit = 8; - } - else - { - // If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels - transcoderChannelLimit = 6; + resultChannels = inputChannels < resultChannels ? inputChannels : resultChannels ?? inputChannels; } var isTranscodingAudio = !IsCopyCodec(codec); - int? resultChannels = state.GetRequestedAudioChannels(codec); if (isTranscodingAudio) { - resultChannels = GetMinValue(request.TranscodingMaxAudioChannels, resultChannels); - } + var audioEncoder = GetAudioEncoder(state); + if (!_audioTranscodeChannelLookup.TryGetValue(audioEncoder, out var transcoderChannelLimit)) + { + // Set default max transcoding channels to 8 to prevent encoding errors due to asking for too many channels. + transcoderChannelLimit = 8; + } - if (inputChannels.HasValue) - { - resultChannels = resultChannels.HasValue - ? Math.Min(resultChannels.Value, inputChannels.Value) - : inputChannels.Value; - } + // Set resultChannels to minimum between resultChannels, TranscodingMaxAudioChannels, transcoderChannelLimit + resultChannels = transcoderChannelLimit < resultChannels ? transcoderChannelLimit : resultChannels ?? transcoderChannelLimit; - if (isTranscodingAudio && transcoderChannelLimit.HasValue) - { - resultChannels = resultChannels.HasValue - ? Math.Min(resultChannels.Value, transcoderChannelLimit.Value) - : transcoderChannelLimit.Value; - } + if (request.TranscodingMaxAudioChannels < resultChannels) + { + resultChannels = request.TranscodingMaxAudioChannels; + } - // Avoid transcoding to audio channels other than 1ch, 2ch, 6ch (5.1 layout) and 8ch (7.1 layout). - // https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices - if (isTranscodingAudio - && state.TranscodingType != TranscodingJobType.Progressive - && resultChannels.HasValue - && ((resultChannels.Value > 2 && resultChannels.Value < 6) || resultChannels.Value == 7)) - { - resultChannels = 2; + // Avoid transcoding to audio channels other than 1ch, 2ch, 6ch (5.1 layout) and 8ch (7.1 layout). + // https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices + if (state.TranscodingType != TranscodingJobType.Progressive + && ((resultChannels > 2 && resultChannels < 6) || resultChannels == 7)) + { + resultChannels = 2; + } } return resultChannels; } - private int? GetMinValue(int? val1, int? val2) - { - if (!val1.HasValue) - { - return val2; - } - - if (!val2.HasValue) - { - return val1; - } - - return Math.Min(val1.Value, val2.Value); - } - /// <summary> /// Enforces the resolution limit. /// </summary> @@ -2439,6 +2670,30 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> + /// Gets the negative map args by filters. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="videoProcessFilters">The videoProcessFilters.</param> + /// <returns>System.String.</returns> + public string GetNegativeMapArgsByFilters(EncodingJobInfo state, string videoProcessFilters) + { + string args = string.Empty; + + // http://ffmpeg.org/ffmpeg-all.html#toc-Complex-filtergraphs-1 + if (state.VideoStream != null && videoProcessFilters.Contains("-filter_complex", StringComparison.Ordinal)) + { + int videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream); + + args += string.Format( + CultureInfo.InvariantCulture, + "-map -0:{0} ", + videoStreamIndex); + } + + return args; + } + + /// <summary> /// Determines which stream will be used for playback. /// </summary> /// <param name="allStream">All stream.</param> @@ -2499,8 +2754,8 @@ namespace MediaBrowser.Controller.MediaEncoding if (outputWidth > maximumWidth || outputHeight > maximumHeight) { - var scaleW = (double)maximumWidth / (double)outputWidth; - var scaleH = (double)maximumHeight / (double)outputHeight; + var scaleW = (double)maximumWidth / outputWidth; + var scaleH = (double)maximumHeight / outputHeight; var scale = Math.Min(scaleW, scaleH); outputWidth = Math.Min(maximumWidth, (int)(outputWidth * scale)); outputHeight = Math.Min(maximumHeight, (int)(outputHeight * scale)); @@ -2647,79 +2902,76 @@ namespace MediaBrowser.Controller.MediaEncoding widthParam, heightParam); } - else - { - return GetFixedSwScaleFilter(threedFormat, requestedWidth.Value, requestedHeight.Value); - } + + return GetFixedSwScaleFilter(threedFormat, requestedWidth.Value, requestedHeight.Value); } // If Max dimensions were supplied, for width selects lowest even number between input width and width req size and selects lowest even number from in width*display aspect and requested size - else if (requestedMaxWidth.HasValue && requestedMaxHeight.HasValue) + + if (requestedMaxWidth.HasValue && requestedMaxHeight.HasValue) { var maxWidthParam = requestedMaxWidth.Value.ToString(CultureInfo.InvariantCulture); var maxHeightParam = requestedMaxHeight.Value.ToString(CultureInfo.InvariantCulture); return string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*a)\\,min({0}\\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\\,ih)\\,min({0}/a\\,{1}))/2)*2", - maxWidthParam, - maxHeightParam, - scaleVal); + CultureInfo.InvariantCulture, + "scale=trunc(min(max(iw\\,ih*a)\\,min({0}\\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\\,ih)\\,min({0}/a\\,{1}))/2)*2", + maxWidthParam, + maxHeightParam, + scaleVal); } // If a fixed width was requested - else if (requestedWidth.HasValue) + if (requestedWidth.HasValue) { if (threedFormat.HasValue) { // This method can handle 0 being passed in for the requested height return GetFixedSwScaleFilter(threedFormat, requestedWidth.Value, 0); } - else - { - var widthParam = requestedWidth.Value.ToString(CultureInfo.InvariantCulture); - return string.Format( - CultureInfo.InvariantCulture, - "scale={0}:trunc(ow/a/2)*2", - widthParam); - } + var widthParam = requestedWidth.Value.ToString(CultureInfo.InvariantCulture); + + return string.Format( + CultureInfo.InvariantCulture, + "scale={0}:trunc(ow/a/2)*2", + widthParam); } // If a fixed height was requested - else if (requestedHeight.HasValue) + if (requestedHeight.HasValue) { var heightParam = requestedHeight.Value.ToString(CultureInfo.InvariantCulture); return string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(oh*a/{1})*{1}:{0}", - heightParam, - scaleVal); + CultureInfo.InvariantCulture, + "scale=trunc(oh*a/{1})*{1}:{0}", + heightParam, + scaleVal); } // If a max width was requested - else if (requestedMaxWidth.HasValue) + if (requestedMaxWidth.HasValue) { var maxWidthParam = requestedMaxWidth.Value.ToString(CultureInfo.InvariantCulture); return string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*a)\\,{0})/{1})*{1}:trunc(ow/a/2)*2", - maxWidthParam, - scaleVal); + CultureInfo.InvariantCulture, + "scale=trunc(min(max(iw\\,ih*a)\\,{0})/{1})*{1}:trunc(ow/a/2)*2", + maxWidthParam, + scaleVal); } // If a max height was requested - else if (requestedMaxHeight.HasValue) + if (requestedMaxHeight.HasValue) { var maxHeightParam = requestedMaxHeight.Value.ToString(CultureInfo.InvariantCulture); return string.Format( - CultureInfo.InvariantCulture, - "scale=trunc(oh*a/{1})*{1}:min(max(iw/a\\,ih)\\,{0})", - maxHeightParam, - scaleVal); + CultureInfo.InvariantCulture, + "scale=trunc(oh*a/{1})*{1}:min(max(iw/a\\,ih)\\,{0})", + maxHeightParam, + scaleVal); } return string.Empty; @@ -2793,22 +3045,32 @@ namespace MediaBrowser.Controller.MediaEncoding "yadif_cuda={0}:-1:0", doubleRateDeint ? "1" : "0"); } - else if (hwDeintSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) + + if (hwDeintSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) { return string.Format( CultureInfo.InvariantCulture, "deinterlace_vaapi=rate={0}", doubleRateDeint ? "field" : "frame"); } - else if (hwDeintSuffix.Contains("qsv", StringComparison.OrdinalIgnoreCase)) + + if (hwDeintSuffix.Contains("qsv", StringComparison.OrdinalIgnoreCase)) { return "deinterlace_qsv=mode=2"; } + if (hwDeintSuffix.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + return string.Format( + CultureInfo.InvariantCulture, + "yadif_videotoolbox={0}:-1:0", + doubleRateDeint ? "1" : "0"); + } + return string.Empty; } - public static string GetHwTonemapFilter(EncodingOptions options, string hwTonemapSuffix, string videoFormat) + public string GetHwTonemapFilter(EncodingOptions options, string hwTonemapSuffix, string videoFormat) { if (string.IsNullOrEmpty(hwTonemapSuffix)) { @@ -2820,7 +3082,8 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(hwTonemapSuffix, "vaapi", StringComparison.OrdinalIgnoreCase)) { - args = "tonemap_vaapi=format={0}:p=bt709:t=bt709:m=bt709,procamp_vaapi=b={1}:c={2}:extra_hw_frames=16"; + args = "procamp_vaapi=b={1}:c={2},tonemap_vaapi=format={0}:p=bt709:t=bt709:m=bt709:extra_hw_frames=32"; + return string.Format( CultureInfo.InvariantCulture, args, @@ -2828,36 +3091,28 @@ namespace MediaBrowser.Controller.MediaEncoding options.VppTonemappingBrightness, options.VppTonemappingContrast); } - else if (string.Equals(hwTonemapSuffix, "vulkan", StringComparison.OrdinalIgnoreCase)) + else { - args = "libplacebo=format={1}:tonemapping={2}:color_primaries=bt709:color_trc=bt709:colorspace=bt709:peak_detect=0:upscaler=none:downscaler=none"; - - if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase)) - { - args += ":range={6}"; - } + args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709:tonemap={2}:peak={3}:desat={4}"; - if (string.Equals(options.TonemappingAlgorithm, "bt2390", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(options.TonemappingMode, "max", StringComparison.OrdinalIgnoreCase) + || string.Equals(options.TonemappingMode, "rgb", StringComparison.OrdinalIgnoreCase)) { - algorithm = "bt.2390"; - } - else if (string.Equals(options.TonemappingAlgorithm, "none", StringComparison.OrdinalIgnoreCase)) - { - algorithm = "clip"; + if (_mediaEncoder.EncoderVersion >= _minFFmpegOclCuTonemapMode) + { + args += ":tonemap_mode={5}"; + } } - } - else - { - args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709:tonemap={2}:peak={3}:desat={4}"; if (options.TonemappingParam != 0) { - args += ":param={5}"; + args += ":param={6}"; } - if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(options.TonemappingRange, "tv", StringComparison.OrdinalIgnoreCase) + || string.Equals(options.TonemappingRange, "pc", StringComparison.OrdinalIgnoreCase)) { - args += ":range={6}"; + args += ":range={7}"; } } @@ -2869,10 +3124,80 @@ namespace MediaBrowser.Controller.MediaEncoding algorithm, options.TonemappingPeak, options.TonemappingDesat, + options.TonemappingMode, options.TonemappingParam, options.TonemappingRange); } + public string GetLibplaceboFilter( + EncodingOptions options, + string videoFormat, + bool doTonemap, + int? videoWidth, + int? videoHeight, + int? requestedWidth, + int? requestedHeight, + int? requestedMaxWidth, + int? requestedMaxHeight) + { + var (outWidth, outHeight) = GetFixedOutputSize( + videoWidth, + videoHeight, + requestedWidth, + requestedHeight, + requestedMaxWidth, + requestedMaxHeight); + + var isFormatFixed = !string.IsNullOrEmpty(videoFormat); + var isSizeFixed = !videoWidth.HasValue + || outWidth.Value != videoWidth.Value + || !videoHeight.HasValue + || outHeight.Value != videoHeight.Value; + + var sizeArg = isSizeFixed ? (":w=" + outWidth.Value + ":h=" + outHeight.Value) : string.Empty; + var formatArg = isFormatFixed ? (":format=" + videoFormat) : string.Empty; + var tonemapArg = string.Empty; + + if (doTonemap) + { + var algorithm = options.TonemappingAlgorithm; + var mode = options.TonemappingMode; + var range = options.TonemappingRange; + + if (string.Equals(algorithm, "bt2390", StringComparison.OrdinalIgnoreCase)) + { + algorithm = "bt.2390"; + } + else if (string.Equals(algorithm, "none", StringComparison.OrdinalIgnoreCase)) + { + algorithm = "clip"; + } + + tonemapArg = ":tonemapping=" + algorithm; + + if (string.Equals(mode, "max", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "rgb", StringComparison.OrdinalIgnoreCase)) + { + tonemapArg += ":tonemapping_mode=" + mode; + } + + tonemapArg += ":peak_detect=0:color_primaries=bt709:color_trc=bt709:colorspace=bt709"; + + if (string.Equals(range, "tv", StringComparison.OrdinalIgnoreCase) + || string.Equals(range, "pc", StringComparison.OrdinalIgnoreCase)) + { + tonemapArg += ":range=" + range; + } + } + + return string.Format( + CultureInfo.InvariantCulture, + "libplacebo=upscaler=none:downscaler=none{0}{1}{2}", + sizeArg, + formatArg, + tonemapArg); + } + /// <summary> /// Gets the parameter of software filter chain. /// </summary> @@ -3273,7 +3598,7 @@ namespace MediaBrowser.Controller.MediaEncoding // OUTPUT nv12 surface(memory) // prefer hwmap to hwdownload on opencl. - var hwTransferFilter = hasGraphicalSubs ? "hwdownload" : "hwmap"; + var hwTransferFilter = hasGraphicalSubs ? "hwdownload" : "hwmap=mode=read"; mainFilters.Add(hwTransferFilter); mainFilters.Add("format=nv12"); } @@ -3447,7 +3772,7 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add(swDeintFilter); } - var outFormat = doOclTonemap ? "yuv420p10le" : "yuv420p"; + var outFormat = doOclTonemap ? "yuv420p10le" : (hasGraphicalSubs ? "yuv420p" : "nv12"); var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); // sw scale mainFilters.Add(swScaleFilter); @@ -3516,7 +3841,7 @@ namespace MediaBrowser.Controller.MediaEncoding // OUTPUT nv12 surface(memory) // prefer hwmap to hwdownload on opencl. // qsv hwmap is not fully implemented for the time being. - mainFilters.Add(isHwmapUsable ? "hwmap" : "hwdownload"); + mainFilters.Add(isHwmapUsable ? "hwmap=mode=read" : "hwdownload"); mainFilters.Add("format=nv12"); } @@ -3648,7 +3973,7 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add(swDeintFilter); } - var outFormat = doOclTonemap ? "yuv420p10le" : "yuv420p"; + var outFormat = doOclTonemap ? "yuv420p10le" : (hasGraphicalSubs ? "yuv420p" : "nv12"); var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); // sw scale mainFilters.Add(swScaleFilter); @@ -3674,6 +3999,13 @@ namespace MediaBrowser.Controller.MediaEncoding var outFormat = doTonemap ? string.Empty : "nv12"; var hwScaleFilter = GetHwScaleFilter(isVaapiDecoder ? "vaapi" : "qsv", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + // allocate extra pool sizes for vaapi vpp + if (!string.IsNullOrEmpty(hwScaleFilter) && isVaapiDecoder) + { + hwScaleFilter += ":extra_hw_frames=24"; + } + // hw scale mainFilters.Add(hwScaleFilter); } @@ -3720,7 +4052,7 @@ namespace MediaBrowser.Controller.MediaEncoding // OUTPUT nv12 surface(memory) // prefer hwmap to hwdownload on opencl/vaapi. // qsv hwmap is not fully implemented for the time being. - mainFilters.Add(isHwmapUsable ? "hwmap" : "hwdownload"); + mainFilters.Add(isHwmapUsable ? "hwmap=mode=read" : "hwdownload"); mainFilters.Add("format=nv12"); } @@ -3949,6 +4281,13 @@ namespace MediaBrowser.Controller.MediaEncoding var outFormat = doTonemap ? string.Empty : "nv12"; var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + // allocate extra pool sizes for vaapi vpp + if (!string.IsNullOrEmpty(hwScaleFilter)) + { + hwScaleFilter += ":extra_hw_frames=24"; + } + // hw scale mainFilters.Add(hwScaleFilter); } @@ -3990,7 +4329,7 @@ namespace MediaBrowser.Controller.MediaEncoding // OUTPUT nv12 surface(memory) // prefer hwmap to hwdownload on opencl/vaapi. - mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap"); + mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap=mode=read"); mainFilters.Add("format=nv12"); } @@ -4088,7 +4427,6 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isVaapiEncoder; - var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); @@ -4117,95 +4455,81 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add(swDeintFilter); } - var outFormat = doVkTonemap ? "yuv420p10le" : "nv12"; - var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); - // sw scale - mainFilters.Add(swScaleFilter); - mainFilters.Add("format=" + outFormat); - - // keep video at memory except vk tonemap, - // since the overhead caused by hwupload >>> using sw filter. - // sw => hw - if (doVkTonemap) + if (doVkTonemap || hasSubs) + { + // sw => hw + mainFilters.Add("hwupload=derive_device=vulkan"); + mainFilters.Add("format=vulkan"); + } + else { - mainFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + // sw scale + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=nv12"); } } else if (isVaapiDecoder) { // INPUT vaapi surface(vram) - // hw deint - if (doDeintH2645) + if (doVkTonemap || hasSubs) { - var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); - mainFilters.Add(deintFilter); + // map from vaapi to vulkan/drm via interop (Vega/gfx9+). + mainFilters.Add("hwmap=derive_device=vulkan"); + mainFilters.Add("format=vulkan"); } - - var outFormat = doVkTonemap ? string.Empty : (hasSubs && isVaInVaOut ? "bgra" : "nv12"); - var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); - - // allocate extra pool sizes for overlay_vulkan - if (!string.IsNullOrEmpty(hwScaleFilter) && isVaInVaOut && hasSubs) + else { - hwScaleFilter += ":extra_hw_frames=32"; - } - - // hw scale - mainFilters.Add(hwScaleFilter); - } + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); + mainFilters.Add(deintFilter); + } - if ((isVaapiDecoder && doVkTonemap) || (isVaInVaOut && (doVkTonemap || hasSubs))) - { - // map from vaapi to vulkan via vaapi-vulkan interop (Vega/gfx9+). - mainFilters.Add("hwmap=derive_device=vulkan"); + // hw scale + var hwScaleFilter = GetHwScaleFilter("vaapi", "nv12", inW, inH, reqW, reqH, reqMaxW, reqMaxH); + mainFilters.Add(hwScaleFilter); + } } - // vk tonemap - if (doVkTonemap) + // vk libplacebo + if (doVkTonemap || hasSubs) { - var outFormat = isVaInVaOut && hasSubs ? "bgra" : "nv12"; - var tonemapFilter = GetHwTonemapFilter(options, "vulkan", outFormat); - mainFilters.Add(tonemapFilter); + var libplaceboFilter = GetLibplaceboFilter(options, "bgra", doVkTonemap, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + mainFilters.Add(libplaceboFilter); } - if (doVkTonemap && isVaInVaOut && !hasSubs) + if (doVkTonemap && !hasSubs) { - // OUTPUT vaapi(nv12/bgra) surface(vram) - // reverse-mapping via vaapi-vulkan interop. - mainFilters.Add("hwmap=derive_device=vaapi:reverse=1"); + // OUTPUT vaapi(nv12) surface(vram) + // map from vulkan/drm to vaapi via interop (Vega/gfx9+). + mainFilters.Add("hwmap=derive_device=drm"); + mainFilters.Add("format=drm_prime"); + mainFilters.Add("hwmap=derive_device=vaapi"); mainFilters.Add("format=vaapi"); - } - var memoryOutput = false; - var isUploadForVkTonemap = isSwDecoder && doVkTonemap; - if ((isVaapiDecoder && isSwEncoder) || isUploadForVkTonemap) - { - memoryOutput = true; + // clear the surf->meta_offset and output nv12 + mainFilters.Add("scale_vaapi=format=nv12"); - // OUTPUT nv12 surface(memory) - mainFilters.Add("hwdownload"); - mainFilters.Add("format=nv12"); - } - - // OUTPUT nv12 surface(memory) - if (isSwDecoder && isVaapiEncoder) - { - memoryOutput = true; + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); + mainFilters.Add(deintFilter); + } } - if (memoryOutput) + if (!hasSubs) { - // text subtitles - if (hasTextSubs) + // OUTPUT nv12 surface(memory) + if (isSwEncoder && (doVkTonemap || isVaapiDecoder)) { - var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); - mainFilters.Add(textSubtitlesFilter); + mainFilters.Add("hwdownload"); + mainFilters.Add("format=nv12"); } - } - if (memoryOutput && isVaapiEncoder) - { - if (!hasGraphicalSubs) + if (isSwDecoder && isVaapiEncoder && !doVkTonemap) { mainFilters.Add("hwupload_vaapi"); } @@ -4214,50 +4538,53 @@ namespace MediaBrowser.Controller.MediaEncoding /* Make sub and overlay filters for subtitle stream */ var subFilters = new List<string>(); var overlayFilters = new List<string>(); - if (isVaInVaOut) + if (hasSubs) { - if (hasSubs) + if (hasGraphicalSubs) { - if (hasGraphicalSubs) - { - // scale=s=1280x720,format=bgra,hwupload - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); - subFilters.Add("format=bgra"); - } - else if (hasTextSubs) - { - var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); - var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); - subFilters.Add(alphaSrcFilter); - subFilters.Add("format=bgra"); - subFilters.Add(subTextSubtitlesFilter); - } - - subFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + // scale=s=1280x720,format=bgra,hwupload + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + subFilters.Add("format=bgra"); + } + else if (hasTextSubs) + { + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=bgra"); + subFilters.Add(subTextSubtitlesFilter); + } - overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0"); + subFilters.Add("hwupload=derive_device=vulkan"); + subFilters.Add("format=vulkan"); - // explicitly sync using libplacebo. - overlayFilters.Add("libplacebo=format=nv12:upscaler=none:downscaler=none"); + overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0"); - // OUTPUT vaapi(nv12/bgra) surface(vram) - // reverse-mapping via vaapi-vulkan interop. - overlayFilters.Add("hwmap=derive_device=vaapi:reverse=1"); - overlayFilters.Add("format=vaapi"); + if (isSwEncoder) + { + // OUTPUT nv12 surface(memory) + overlayFilters.Add("scale_vulkan=format=nv12"); + overlayFilters.Add("hwdownload"); + overlayFilters.Add("format=nv12"); } - } - else if (memoryOutput) - { - if (hasGraphicalSubs) + else if (isVaapiEncoder) { - var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); - subFilters.Add(subSwScaleFilter); - overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); + // OUTPUT vaapi(nv12) surface(vram) + // map from vulkan/drm to vaapi via interop (Vega/gfx9+). + overlayFilters.Add("hwmap=derive_device=drm"); + overlayFilters.Add("format=drm_prime"); + overlayFilters.Add("hwmap=derive_device=vaapi"); + overlayFilters.Add("format=vaapi"); - if (isVaapiEncoder) + // clear the surf->meta_offset and output nv12 + overlayFilters.Add("scale_vaapi=format=nv12"); + + // hw deint + if (doDeintH2645) { - overlayFilters.Add("hwupload_vaapi"); + var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); + overlayFilters.Add(deintFilter); } } } @@ -4338,6 +4665,13 @@ namespace MediaBrowser.Controller.MediaEncoding outFormat = doOclTonemap ? string.Empty : "nv12"; var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + // allocate extra pool sizes for vaapi vpp + if (!string.IsNullOrEmpty(hwScaleFilter)) + { + hwScaleFilter += ":extra_hw_frames=24"; + } + // hw scale mainFilters.Add(hwScaleFilter); } @@ -4441,6 +4775,75 @@ namespace MediaBrowser.Controller.MediaEncoding } /// <summary> + /// Gets the parameter of Apple VideoToolBox filter chain. + /// </summary> + /// <param name="state">Encoding state.</param> + /// <param name="options">Encoding options.</param> + /// <param name="vidEncoder">Video encoder to use.</param> + /// <returns>The tuple contains three lists: main, sub and overlay filters.</returns> + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetAppleVidFilterChain( + EncodingJobInfo state, + EncodingOptions options, + string vidEncoder) + { + if (!string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + return (null, null, null); + } + + var swFilterChain = GetSwVidFilterChain(state, options, vidEncoder); + + if (!options.EnableHardwareEncoding) + { + return swFilterChain; + } + + if (_mediaEncoder.EncoderVersion.CompareTo(new Version("5.0.0")) < 0) + { + // All features used here requires ffmpeg 5.0 or later, fallback to software filters if using an old ffmpeg + return swFilterChain; + } + + 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 inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + var newfilters = new List<string>(); + var noOverlay = swFilterChain.OverlayFilters.Count == 0; + var supportsHwDeint = _mediaEncoder.SupportsFilter("yadif_videotoolbox"); + // fallback to software filters if we are using filters not supported by hardware yet. + var useHardwareFilters = noOverlay && (!doDeintH2645 || supportsHwDeint); + + if (!useHardwareFilters) + { + return swFilterChain; + } + + // ffmpeg cannot use videotoolbox to scale + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + newfilters.Add(swScaleFilter); + + // hwupload on videotoolbox encoders can automatically convert AVFrame into its CVPixelBuffer equivalent + // videotoolbox will automatically convert the CVPixelBuffer to a pixel format the encoder supports, so we don't have to set a pixel format explicitly here + // This will reduce CPU usage significantly on UHD videos with 10 bit colors because we bypassed the ffmpeg pixel format conversion + newfilters.Add("hwupload"); + + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "videotoolbox"); + newfilters.Add(deintFilter); + } + + return (newfilters, swFilterChain.SubFilters, swFilterChain.OverlayFilters); + } + + /// <summary> /// Gets the parameter of video processing filters. /// </summary> /// <param name="state">Encoding state.</param> @@ -4482,6 +4885,10 @@ namespace MediaBrowser.Controller.MediaEncoding { (mainFilters, subFilters, overlayFilters) = GetAmdVidFilterChain(state, options, outputVideoCodec); } + else if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + (mainFilters, subFilters, overlayFilters) = GetAppleVidFilterChain(state, options, outputVideoCodec); + } else { (mainFilters, subFilters, overlayFilters) = GetSwVidFilterChain(state, options, outputVideoCodec); @@ -4601,26 +5008,27 @@ namespace MediaBrowser.Controller.MediaEncoding { return videoStream.BitDepth.Value; } - else if (string.Equals(videoStream.PixelFormat, "yuv420p", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.PixelFormat, "yuvj420p", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.PixelFormat, "yuv444p", StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(videoStream.PixelFormat, "yuv420p", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuvj420p", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p", StringComparison.OrdinalIgnoreCase)) { return 8; } - else if (string.Equals(videoStream.PixelFormat, "yuv420p10le", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.PixelFormat, "yuv444p10le", StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(videoStream.PixelFormat, "yuv420p10le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p10le", StringComparison.OrdinalIgnoreCase)) { return 10; } - else if (string.Equals(videoStream.PixelFormat, "yuv420p12le", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.PixelFormat, "yuv444p12le", StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(videoStream.PixelFormat, "yuv420p12le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p12le", StringComparison.OrdinalIgnoreCase)) { return 12; } - else - { - return 8; - } + + return 8; } return 0; @@ -4642,7 +5050,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // HWA decoders can handle both video files and video folders. - var videoType = mediaSource.VideoType; + var videoType = state.VideoType; if (videoType != VideoType.VideoFile && videoType != VideoType.Iso && videoType != VideoType.Dvd @@ -4783,8 +5191,18 @@ namespace MediaBrowser.Controller.MediaEncoding var isVideotoolboxSupported = isMacOS && _mediaEncoder.SupportsHwaccel("videotoolbox"); var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase); + var ffmpegVersion = _mediaEncoder.EncoderVersion; + // Set the av1 codec explicitly to trigger hw accelerator, otherwise libdav1d will be used. - var isAv1 = string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase); + var isAv1 = ffmpegVersion < _minFFmpegImplictHwaccel + && string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase); + + // Allow profile mismatch if decoding H.264 baseline with d3d11va and vaapi hwaccels. + var profileMismatch = string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.VideoStream?.Profile, "baseline", StringComparison.OrdinalIgnoreCase); + + // Disable the extra internal copy in nvdec. We already handle it in filter chain. + var nvdecNoInternalCopy = ffmpegVersion >= _minFFmpegHwaUnsafeOutput; if (bitDepth == 10 && isCodecAvailable) { @@ -4810,14 +5228,16 @@ namespace MediaBrowser.Controller.MediaEncoding { if (isVaapiSupported && isCodecAvailable) { - return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); } if (isD3d11Supported && isCodecAvailable) { // set -threads 3 to intel d3d11va decoder explicitly. Lower threads may result in dead lock. // on newer devices such as Xe, the larger the init_pool_size, the longer the initialization time for opencl to derive from d3d11. - return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + " -threads 3" + (isAv1 ? " -c:v av1" : string.Empty); + return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + " -threads 3" + (isAv1 ? " -c:v av1" : string.Empty); } } else @@ -4837,13 +5257,12 @@ namespace MediaBrowser.Controller.MediaEncoding if (options.EnableEnhancedNvdecDecoder) { // set -threads 1 to nvdec decoder explicitly since it doesn't implement threading support. - return " -hwaccel cuda" + (outputHwSurface ? " -hwaccel_output_format cuda" : string.Empty) + " -threads 1" + (isAv1 ? " -c:v av1" : string.Empty); - } - else - { - // cuvid decoder doesn't have threading issue. - return " -hwaccel cuda" + (outputHwSurface ? " -hwaccel_output_format cuda" : string.Empty); + return " -hwaccel cuda" + (outputHwSurface ? " -hwaccel_output_format cuda" : string.Empty) + + (nvdecNoInternalCopy ? " -hwaccel_flags +unsafe_output" : string.Empty) + " -threads 1" + (isAv1 ? " -c:v av1" : string.Empty); } + + // cuvid decoder doesn't have threading issue. + return " -hwaccel cuda" + (outputHwSurface ? " -hwaccel_output_format cuda" : string.Empty); } } @@ -4852,7 +5271,8 @@ namespace MediaBrowser.Controller.MediaEncoding { if (isD3d11Supported && isCodecAvailable) { - return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); } } @@ -4861,9 +5281,11 @@ namespace MediaBrowser.Controller.MediaEncoding && isVaapiSupported && isCodecAvailable) { - return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); + return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty); } + // Apple videotoolbox if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase) && isVideotoolboxSupported && isCodecAvailable) @@ -5198,7 +5620,8 @@ namespace MediaBrowser.Controller.MediaEncoding // Automatically set thread count return mustSetThreadCount ? Math.Max(Environment.ProcessorCount - 1, 1) : 0; } - else if (threads >= Environment.ProcessorCount) + + if (threads >= Environment.ProcessorCount) { return Environment.ProcessorCount; } @@ -5483,14 +5906,22 @@ namespace MediaBrowser.Controller.MediaEncoding } var inputChannels = audioStream is null ? 6 : audioStream.Channels ?? 6; + var shiftAudioCodecs = new List<string>(); if (inputChannels >= 6) { - return; + // DTS and TrueHD are not supported by HLS + // Keep them in the supported codecs list, but shift them to the end of the list so that if transcoding happens, another codec is used + shiftAudioCodecs.Add("dca"); + shiftAudioCodecs.Add("truehd"); + } + else + { + // Transcoding to 2ch ac3 or eac3 almost always causes a playback failure + // Keep them in the supported codecs list, but shift them to the end of the list so that if transcoding happens, another codec is used + shiftAudioCodecs.Add("ac3"); + shiftAudioCodecs.Add("eac3"); } - // Transcoding to 2ch ac3 almost always causes a playback failure - // Keep it in the supported codecs list, but shift it to the end of the list so that if transcoding happens, another codec is used - var shiftAudioCodecs = new[] { "ac3", "eac3" }; if (audioCodecs.All(i => shiftAudioCodecs.Contains(i, StringComparison.OrdinalIgnoreCase))) { return; @@ -5506,19 +5937,25 @@ namespace MediaBrowser.Controller.MediaEncoding private void ShiftVideoCodecsIfNeeded(List<string> videoCodecs, EncodingOptions encodingOptions) { - // Shift hevc/h265 to the end of list if hevc encoding is not allowed. - if (encodingOptions.AllowHevcEncoding) + // No need to shift if there is only one supported video codec. + if (videoCodecs.Count < 2) { return; } - // No need to shift if there is only one supported video codec. - if (videoCodecs.Count < 2) + // Shift codecs to the end of list if it's not allowed. + var shiftVideoCodecs = new List<string>(); + if (!encodingOptions.AllowHevcEncoding) { - return; + shiftVideoCodecs.Add("hevc"); + shiftVideoCodecs.Add("h265"); + } + + if (!encodingOptions.AllowAv1Encoding) + { + shiftVideoCodecs.Add("av1"); } - var shiftVideoCodecs = new[] { "hevc", "h265" }; if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparison.OrdinalIgnoreCase))) { return; @@ -5667,7 +6104,9 @@ namespace MediaBrowser.Controller.MediaEncoding // video processing filters. var videoProcessParam = GetVideoProcessingFilterParam(state, encodingOptions, videoCodec); - args += videoProcessParam; + var negativeMapArgs = GetNegativeMapArgsByFilters(state, videoProcessParam); + + args = negativeMapArgs + args + videoProcessParam; hasCopyTs = videoProcessParam.Contains("copyts", StringComparison.OrdinalIgnoreCase); @@ -5730,10 +6169,17 @@ namespace MediaBrowser.Controller.MediaEncoding } var bitrate = state.OutputAudioBitrate; - - if (bitrate.HasValue) + if (bitrate.HasValue && !LosslessAudioCodecs.Contains(codec, StringComparison.OrdinalIgnoreCase)) { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + var vbrParam = GetAudioVbrModeParam(codec, bitrate.Value / (channels ?? 2)); + if (encodingOptions.EnableAudioVbr && vbrParam is not null) + { + args += vbrParam; + } + else + { + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + } } if (state.OutputAudioSampleRate.HasValue) @@ -5751,18 +6197,33 @@ namespace MediaBrowser.Controller.MediaEncoding var audioTranscodeParams = new List<string>(); var bitrate = state.OutputAudioBitrate; + var channels = state.OutputAudioChannels; + var outputCodec = state.OutputAudioCodec; - if (bitrate.HasValue) + if (bitrate.HasValue && !LosslessAudioCodecs.Contains(outputCodec, StringComparison.OrdinalIgnoreCase)) { - audioTranscodeParams.Add("-ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture)); + var vbrParam = GetAudioVbrModeParam(GetAudioEncoder(state), bitrate.Value / (channels ?? 2)); + if (encodingOptions.EnableAudioVbr && vbrParam is not null) + { + audioTranscodeParams.Add(vbrParam); + } + else + { + audioTranscodeParams.Add("-ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture)); + } } - if (state.OutputAudioChannels.HasValue) + if (channels.HasValue) { audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); } - if (!string.Equals(state.OutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(outputCodec)) + { + audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state)); + } + + if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) { // opus only supports specific sampling rates var sampleRate = state.OutputAudioSampleRate; @@ -5781,6 +6242,13 @@ namespace MediaBrowser.Controller.MediaEncoding } } + // Copy the movflags from GetProgressiveVideoFullCommandLine + // See #9248 and the associated PR for why this is needed + if (_mp4ContainerNames.Contains(state.OutputContainer)) + { + audioTranscodeParams.Add("-movflags empty_moov+delay_moov"); + } + var threads = GetNumberOfThreads(state, encodingOptions, null); var inputModifier = GetInputModifier(state, encodingOptions, null); diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 179cabc84a..17813559a8 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; @@ -250,8 +251,7 @@ namespace MediaBrowser.Controller.MediaEncoding } var level = GetRequestedLevel(ActualOutputVideoCodec); - if (!string.IsNullOrEmpty(level) - && double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (double.TryParse(level, CultureInfo.InvariantCulture, out var result)) { return result; } @@ -368,22 +368,21 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the target video range type. /// </summary> - public string TargetVideoRangeType + public VideoRangeType TargetVideoRangeType { get { if (BaseRequest.Static || EncodingHelper.IsCopyCodec(OutputVideoCodec)) { - return VideoStream?.VideoRangeType; + return VideoStream?.VideoRangeType ?? VideoRangeType.Unknown; } - var requestedRangeType = GetRequestedRangeTypes(ActualOutputVideoCodec).FirstOrDefault(); - if (!string.IsNullOrEmpty(requestedRangeType)) + if (Enum.TryParse(GetRequestedRangeTypes(ActualOutputVideoCodec).FirstOrDefault() ?? "Unknown", true, out VideoRangeType requestedRangeType)) { return requestedRangeType; } - return null; + return VideoRangeType.Unknown; } } @@ -645,8 +644,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codec)) { var value = BaseRequest.GetOption(codec, "maxrefframes"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } @@ -665,8 +663,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codec)) { var value = BaseRequest.GetOption(codec, "videobitdepth"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } @@ -685,8 +682,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codec)) { var value = BaseRequest.GetOption(codec, "audiobitdepth"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } @@ -700,8 +696,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(codec)) { var value = BaseRequest.GetOption(codec, "audiochannels"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index bc6207ac51..f830b9f298 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -154,6 +154,14 @@ namespace MediaBrowser.Controller.MediaEncoding string GetInputArgument(string inputFile, MediaSourceInfo mediaSource); /// <summary> + /// Gets the input argument. + /// </summary> + /// <param name="inputFiles">The input files.</param> + /// <param name="mediaSource">The mediaSource.</param> + /// <returns>System.String.</returns> + string GetInputArgument(IReadOnlyList<string> inputFiles, MediaSourceInfo mediaSource); + + /// <summary> /// Gets the input argument for an external subtitle file. /// </summary> /// <param name="inputFile">The input file.</param> @@ -187,5 +195,27 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="path">The path.</param> /// <param name="pathType">The type of path.</param> void UpdateEncoderPath(string path, string pathType); + + /// <summary> + /// Gets the primary playlist of .vob files. + /// </summary> + /// <param name="path">The to the .vob files.</param> + /// <param name="titleNumber">The title number to start with.</param> + /// <returns>A playlist.</returns> + IReadOnlyList<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber); + + /// <summary> + /// Gets the primary playlist of .m2ts files. + /// </summary> + /// <param name="path">The to the .m2ts files.</param> + /// <returns>A playlist.</returns> + IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path); + + /// <summary> + /// Generates a FFmpeg concat config for the source. + /// </summary> + /// <param name="source">The <see cref="MediaSourceInfo"/>.</param> + /// <param name="concatFilePath">The path the config should be written to.</param> + void GenerateConcatConfig(MediaSourceInfo source, string concatFilePath); } } diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs index d8475f12ae..3b34af4e96 100644 --- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs +++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs @@ -86,7 +86,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var rate = parts[i + 1]; - if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) + if (float.TryParse(rate, CultureInfo.InvariantCulture, out var val)) { framerate = val; } @@ -95,7 +95,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var rate = part.Split('=', 2)[^1]; - if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) + if (float.TryParse(rate, CultureInfo.InvariantCulture, out var val)) { framerate = val; } @@ -127,7 +127,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (scale.HasValue) { - if (long.TryParse(size, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) + if (long.TryParse(size, CultureInfo.InvariantCulture, out var val)) { bytesTranscoded = val * scale.Value; } @@ -146,7 +146,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (scale.HasValue) { - if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val)) + if (float.TryParse(rate, CultureInfo.InvariantCulture, out var val)) { bitRate = (int)Math.Ceiling(val * scale.Value); } diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index fc9ea37d1e..a07d9b3eb4 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -9,7 +9,7 @@ using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Model.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -169,7 +169,7 @@ namespace MediaBrowser.Controller.Net if (data is not null) { await connection.SendAsync( - new WebSocketMessage<TReturnDataType> + new OutboundWebSocketMessage<TReturnDataType> { MessageId = Guid.NewGuid(), MessageType = Type, @@ -232,6 +232,11 @@ namespace MediaBrowser.Controller.Net // TODO Investigate and properly fix. Logger.LogError(ex, "Object Disposed"); } + catch (Exception ex) + { + // TODO Investigate and properly fix. + Logger.LogError(ex, "Error disposing websocket"); + } lock (_activeConnections) { diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index 4f2492b891..04b333230d 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -5,7 +5,6 @@ using System.Net; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Model.Net; namespace MediaBrowser.Controller.Net { diff --git a/MediaBrowser.Controller/Net/WebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessage.cs new file mode 100644 index 0000000000..92183e7929 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessage.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net; + +/// <summary> +/// Websocket message without data. +/// </summary> +public abstract class WebSocketMessage +{ + /// <summary> + /// Gets or sets the type of the message. + /// TODO make this abstract and get only. + /// </summary> + public virtual SessionMessageType MessageType { get; set; } + + /// <summary> + /// Gets or sets the server id. + /// </summary> + [JsonIgnore] + public string? ServerId { get; set; } +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs b/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs index 6f7ebf1565..2d986b7b34 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs @@ -1,7 +1,5 @@ #nullable disable -using MediaBrowser.Model.Net; - namespace MediaBrowser.Controller.Net { /// <summary> diff --git a/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs b/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs new file mode 100644 index 0000000000..7c35c8010d --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs @@ -0,0 +1,33 @@ +#pragma warning disable SA1649 // File name must equal class name. + +namespace MediaBrowser.Controller.Net; + +/// <summary> +/// Class WebSocketMessage. +/// </summary> +/// <typeparam name="T">The type of the data.</typeparam> +// TODO make this abstract, remove empty ctor. +public class WebSocketMessage<T> : WebSocketMessage +{ + /// <summary> + /// Initializes a new instance of the <see cref="WebSocketMessage{T}"/> class. + /// </summary> + public WebSocketMessage() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="WebSocketMessage{T}"/> class. + /// </summary> + /// <param name="data">The data to send.</param> + protected WebSocketMessage(T data) + { + Data = data; + } + + /// <summary> + /// Gets or sets the data. + /// </summary> + // TODO make this set only. + public T? Data { get; set; } +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/IInboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/IInboundWebSocketMessage.cs new file mode 100644 index 0000000000..c3cf9955ad --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/IInboundWebSocketMessage.cs @@ -0,0 +1,10 @@ +#pragma warning disable CA1040 + +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Interface representing that the websocket message is inbound. +/// </summary> +public interface IInboundWebSocketMessage +{ +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/IOutboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/IOutboundWebSocketMessage.cs new file mode 100644 index 0000000000..c74a254a68 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/IOutboundWebSocketMessage.cs @@ -0,0 +1,10 @@ +#pragma warning disable CA1040 + +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Interface representing that the websocket message is outbound. +/// </summary> +public interface IOutboundWebSocketMessage +{ +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs new file mode 100644 index 0000000000..b3a60199a9 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Activity log entry start message. +/// Data is the timing data encoded as "$initialDelay,$interval" in ms. +/// </summary> +public class ActivityLogEntryStartMessage : InboundWebSocketMessage<string> +{ + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogEntryStartMessage"/> class. + /// Data is the timing data encoded as "$initialDelay,$interval" in ms. + /// </summary> + /// <param name="data">The timing data encoded as "$initialDelay,$interval".</param> + public ActivityLogEntryStartMessage(string data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ActivityLogEntryStart)] + public override SessionMessageType MessageType => SessionMessageType.ActivityLogEntryStart; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs new file mode 100644 index 0000000000..6f65cb2c77 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Activity log entry stop message. +/// </summary> +public class ActivityLogEntryStopMessage : InboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ActivityLogEntryStop)] + public override SessionMessageType MessageType => SessionMessageType.ActivityLogEntryStop; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/InboundKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/InboundKeepAliveMessage.cs new file mode 100644 index 0000000000..fec7cb4e41 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/InboundKeepAliveMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Keep alive websocket messages. +/// </summary> +public class InboundKeepAliveMessage : InboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.KeepAlive)] + public override SessionMessageType MessageType => SessionMessageType.KeepAlive; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs new file mode 100644 index 0000000000..bf98470bf2 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Scheduled tasks info start message. +/// Data is the timing data encoded as "$initialDelay,$interval" in ms. +/// </summary> +public class ScheduledTasksInfoStartMessage : InboundWebSocketMessage<string> +{ + /// <summary> + /// Initializes a new instance of the <see cref="ScheduledTasksInfoStartMessage"/> class. + /// </summary> + /// <param name="data">The timing data encoded as $initialDelay,$interval.</param> + public ScheduledTasksInfoStartMessage(string data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ScheduledTasksInfoStart)] + public override SessionMessageType MessageType => SessionMessageType.ScheduledTasksInfoStart; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs new file mode 100644 index 0000000000..f36739c70a --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Scheduled tasks info stop message. +/// </summary> +public class ScheduledTasksInfoStopMessage : InboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ScheduledTasksInfoStop)] + public override SessionMessageType MessageType => SessionMessageType.ScheduledTasksInfoStop; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs new file mode 100644 index 0000000000..a40a0c79ee --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Sessions start message. +/// Data is the timing data encoded as "$initialDelay,$interval" in ms. +/// </summary> +public class SessionsStartMessage : InboundWebSocketMessage<string> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SessionsStartMessage"/> class. + /// </summary> + /// <param name="data">The timing data encoded as $initialDelay,$interval.</param> + public SessionsStartMessage(string data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SessionsStart)] + public override SessionMessageType MessageType => SessionMessageType.SessionsStart; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs new file mode 100644 index 0000000000..288d111c5c --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Sessions stop message. +/// </summary> +public class SessionsStopMessage : InboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SessionsStop)] + public override SessionMessageType MessageType => SessionMessageType.SessionsStop; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs new file mode 100644 index 0000000000..8d6e821df8 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs @@ -0,0 +1,8 @@ +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Inbound websocket message. +/// </summary> +public class InboundWebSocketMessage : WebSocketMessage, IInboundWebSocketMessage +{ +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessageOfT.cs b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessageOfT.cs new file mode 100644 index 0000000000..4da5e7d31f --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessageOfT.cs @@ -0,0 +1,26 @@ +#pragma warning disable SA1649 // File name must equal class name. + +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Inbound websocket message with data. +/// </summary> +/// <typeparam name="T">The data type.</typeparam> +public class InboundWebSocketMessage<T> : WebSocketMessage<T>, IInboundWebSocketMessage +{ + /// <summary> + /// Initializes a new instance of the <see cref="InboundWebSocketMessage{T}"/> class. + /// </summary> + public InboundWebSocketMessage() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="InboundWebSocketMessage{T}"/> class. + /// </summary> + /// <param name="data">The data to send.</param> + protected InboundWebSocketMessage(T data) + { + Data = data; + } +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs new file mode 100644 index 0000000000..2a098615d5 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel; +using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Activity log created message. +/// </summary> +public class ActivityLogEntryMessage : OutboundWebSocketMessage<IReadOnlyList<ActivityLogEntry>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogEntryMessage"/> class. + /// </summary> + /// <param name="data">List of activity log entries.</param> + public ActivityLogEntryMessage(IReadOnlyList<ActivityLogEntry> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ActivityLogEntry)] + public override SessionMessageType MessageType => SessionMessageType.ActivityLogEntry; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs new file mode 100644 index 0000000000..ca55340a05 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Force keep alive websocket messages. +/// </summary> +public class ForceKeepAliveMessage : OutboundWebSocketMessage<int> +{ + /// <summary> + /// Initializes a new instance of the <see cref="ForceKeepAliveMessage"/> class. + /// </summary> + /// <param name="data">The timeout in seconds.</param> + public ForceKeepAliveMessage(int data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ForceKeepAlive)] + public override SessionMessageType MessageType => SessionMessageType.ForceKeepAlive; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs new file mode 100644 index 0000000000..5fbbb06242 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// General command websocket message. +/// </summary> +public class GeneralCommandMessage : OutboundWebSocketMessage<GeneralCommand> +{ + /// <summary> + /// Initializes a new instance of the <see cref="GeneralCommandMessage"/> class. + /// </summary> + /// <param name="data">The general command.</param> + public GeneralCommandMessage(GeneralCommand data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.GeneralCommand)] + public override SessionMessageType MessageType => SessionMessageType.GeneralCommand; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs new file mode 100644 index 0000000000..47417c4059 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Library changed message. +/// </summary> +public class LibraryChangedMessage : OutboundWebSocketMessage<LibraryUpdateInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="LibraryChangedMessage"/> class. + /// </summary> + /// <param name="data">The library update info.</param> + public LibraryChangedMessage(LibraryUpdateInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.LibraryChanged)] + public override SessionMessageType MessageType => SessionMessageType.LibraryChanged; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/OutboundKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/OutboundKeepAliveMessage.cs new file mode 100644 index 0000000000..d907dcff95 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/OutboundKeepAliveMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Keep alive websocket messages. +/// </summary> +public class OutboundKeepAliveMessage : OutboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.KeepAlive)] + public override SessionMessageType MessageType => SessionMessageType.KeepAlive; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs new file mode 100644 index 0000000000..86ee2ff900 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Play command websocket message. +/// </summary> +public class PlayMessage : OutboundWebSocketMessage<PlayRequest> +{ + /// <summary> + /// Initializes a new instance of the <see cref="PlayMessage"/> class. + /// </summary> + /// <param name="data">The play request.</param> + public PlayMessage(PlayRequest data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.Play)] + public override SessionMessageType MessageType => SessionMessageType.Play; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs new file mode 100644 index 0000000000..cd6d28cb30 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Playstate message. +/// </summary> +public class PlaystateMessage : OutboundWebSocketMessage<PlaystateRequest> +{ + /// <summary> + /// Initializes a new instance of the <see cref="PlaystateMessage"/> class. + /// </summary> + /// <param name="data">Playstate request data.</param> + public PlaystateMessage(PlaystateRequest data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.Playstate)] + public override SessionMessageType MessageType => SessionMessageType.Playstate; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs new file mode 100644 index 0000000000..17fd259384 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.Updates; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Plugin installation cancelled message. +/// </summary> +public class PluginInstallationCancelledMessage : OutboundWebSocketMessage<InstallationInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="PluginInstallationCancelledMessage"/> class. + /// </summary> + /// <param name="data">Installation info.</param> + public PluginInstallationCancelledMessage(InstallationInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.PackageInstallationCancelled)] + public override SessionMessageType MessageType => SessionMessageType.PackageInstallationCancelled; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs new file mode 100644 index 0000000000..3e60198bac --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.Updates; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Plugin installation completed message. +/// </summary> +public class PluginInstallationCompletedMessage : OutboundWebSocketMessage<InstallationInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="PluginInstallationCompletedMessage"/> class. + /// </summary> + /// <param name="data">Installation info.</param> + public PluginInstallationCompletedMessage(InstallationInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.PackageInstallationCompleted)] + public override SessionMessageType MessageType => SessionMessageType.PackageInstallationCompleted; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs new file mode 100644 index 0000000000..40032f16e4 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.Updates; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Plugin installation failed message. +/// </summary> +public class PluginInstallationFailedMessage : OutboundWebSocketMessage<InstallationInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="PluginInstallationFailedMessage"/> class. + /// </summary> + /// <param name="data">Installation info.</param> + public PluginInstallationFailedMessage(InstallationInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.PackageInstallationFailed)] + public override SessionMessageType MessageType => SessionMessageType.PackageInstallationFailed; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs new file mode 100644 index 0000000000..28861896f7 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.Updates; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Package installing message. +/// </summary> +public class PluginInstallingMessage : OutboundWebSocketMessage<InstallationInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="PluginInstallingMessage"/> class. + /// </summary> + /// <param name="data">Installation info.</param> + public PluginInstallingMessage(InstallationInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.PackageInstalling)] + public override SessionMessageType MessageType => SessionMessageType.PackageInstalling; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs new file mode 100644 index 0000000000..ca49591194 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Plugin uninstalled message. +/// </summary> +public class PluginUninstalledMessage : OutboundWebSocketMessage<PluginInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="PluginUninstalledMessage"/> class. + /// </summary> + /// <param name="data">Plugin info.</param> + public PluginUninstalledMessage(PluginInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.PackageUninstalled)] + public override SessionMessageType MessageType => SessionMessageType.PackageUninstalled; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs new file mode 100644 index 0000000000..41b3cd46ab --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Refresh progress message. +/// </summary> +public class RefreshProgressMessage : OutboundWebSocketMessage<Dictionary<string, string>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="RefreshProgressMessage"/> class. + /// </summary> + /// <param name="data">Refresh progress data.</param> + public RefreshProgressMessage(Dictionary<string, string> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.RefreshProgress)] + public override SessionMessageType MessageType => SessionMessageType.RefreshProgress; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs new file mode 100644 index 0000000000..a89f19b617 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Restart required. +/// </summary> +public class RestartRequiredMessage : OutboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.RestartRequired)] + public override SessionMessageType MessageType => SessionMessageType.RestartRequired; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs new file mode 100644 index 0000000000..afa36fb722 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.Tasks; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Scheduled task ended message. +/// </summary> +public class ScheduledTaskEndedMessage : OutboundWebSocketMessage<TaskResult> +{ + /// <summary> + /// Initializes a new instance of the <see cref="ScheduledTaskEndedMessage"/> class. + /// </summary> + /// <param name="data">Task result.</param> + public ScheduledTaskEndedMessage(TaskResult data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ScheduledTaskEnded)] + public override SessionMessageType MessageType => SessionMessageType.ScheduledTaskEnded; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs new file mode 100644 index 0000000000..c7360779f9 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.Tasks; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Scheduled tasks info message. +/// </summary> +public class ScheduledTasksInfoMessage : OutboundWebSocketMessage<IReadOnlyList<TaskInfo>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="ScheduledTasksInfoMessage"/> class. + /// </summary> + /// <param name="data">List of task infos.</param> + public ScheduledTasksInfoMessage(IReadOnlyList<TaskInfo> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ScheduledTasksInfo)] + public override SessionMessageType MessageType => SessionMessageType.ScheduledTasksInfo; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs new file mode 100644 index 0000000000..f832c8935e --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Series timer cancelled message. +/// </summary> +public class SeriesTimerCancelledMessage : OutboundWebSocketMessage<TimerEventInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SeriesTimerCancelledMessage"/> class. + /// </summary> + /// <param name="data">The timer event info.</param> + public SeriesTimerCancelledMessage(TimerEventInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SeriesTimerCancelled)] + public override SessionMessageType MessageType => SessionMessageType.SeriesTimerCancelled; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs new file mode 100644 index 0000000000..450b4c7994 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Series timer created message. +/// </summary> +public class SeriesTimerCreatedMessage : OutboundWebSocketMessage<TimerEventInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SeriesTimerCreatedMessage"/> class. + /// </summary> + /// <param name="data">timer event info.</param> + public SeriesTimerCreatedMessage(TimerEventInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SeriesTimerCreated)] + public override SessionMessageType MessageType => SessionMessageType.SeriesTimerCreated; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs new file mode 100644 index 0000000000..8f09c802fe --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Server restarting down message. +/// </summary> +public class ServerRestartingMessage : OutboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ServerRestarting)] + public override SessionMessageType MessageType => SessionMessageType.ServerRestarting; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs new file mode 100644 index 0000000000..485e71b6e3 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Server shutting down message. +/// </summary> +public class ServerShuttingDownMessage : OutboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.ServerShuttingDown)] + public override SessionMessageType MessageType => SessionMessageType.ServerShuttingDown; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs new file mode 100644 index 0000000000..3504831b87 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Sessions message. +/// </summary> +public class SessionsMessage : OutboundWebSocketMessage<IReadOnlyList<SessionInfo>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SessionsMessage"/> class. + /// </summary> + /// <param name="data">Session info.</param> + public SessionsMessage(IReadOnlyList<SessionInfo> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.Sessions)] + public override SessionMessageType MessageType => SessionMessageType.Sessions; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs new file mode 100644 index 0000000000..d0624ec016 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Sync play command. +/// </summary> +public class SyncPlayCommandMessage : OutboundWebSocketMessage<SendCommand> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayCommandMessage"/> class. + /// </summary> + /// <param name="data">The send command.</param> + public SyncPlayCommandMessage(SendCommand data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SyncPlayCommand)] + public override SessionMessageType MessageType => SessionMessageType.SyncPlayCommand; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs new file mode 100644 index 0000000000..6a501aa7ea --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Untyped sync play command. +/// </summary> +public class SyncPlayGroupUpdateCommandMessage : OutboundWebSocketMessage<GroupUpdate> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandMessage"/> class. + /// </summary> + /// <param name="data">The send command.</param> + public SyncPlayGroupUpdateCommandMessage(GroupUpdate data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] + public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs new file mode 100644 index 0000000000..47f706e2a4 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Sync play group update command with group info. +/// GroupUpdateTypes: GroupJoined. +/// </summary> +public class SyncPlayGroupUpdateCommandOfGroupInfoMessage : OutboundWebSocketMessage<GroupUpdate<GroupInfoDto>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfGroupInfoMessage"/> class. + /// </summary> + /// <param name="data">The group info.</param> + public SyncPlayGroupUpdateCommandOfGroupInfoMessage(GroupUpdate<GroupInfoDto> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] + public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs new file mode 100644 index 0000000000..11ddb1e250 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Sync play group update command with group state update. +/// GroupUpdateTypes: StateUpdate. +/// </summary> +public class SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage : OutboundWebSocketMessage<GroupUpdate<GroupStateUpdate>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage"/> class. + /// </summary> + /// <param name="data">The group info.</param> + public SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage(GroupUpdate<GroupStateUpdate> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] + public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs new file mode 100644 index 0000000000..7e73399b1b --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Sync play group update command with play queue update. +/// GroupUpdateTypes: PlayQueue. +/// </summary> +public class SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage : OutboundWebSocketMessage<GroupUpdate<PlayQueueUpdate>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage"/> class. + /// </summary> + /// <param name="data">The play queue update.</param> + public SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage(GroupUpdate<PlayQueueUpdate> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] + public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs new file mode 100644 index 0000000000..5b5ccd3eda --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Sync play group update command with string. +/// GroupUpdateTypes: GroupDoesNotExist (error), LibraryAccessDenied (error), NotInGroup (error), GroupLeft (groupId), UserJoined (username), UserLeft (username). +/// </summary> +public class SyncPlayGroupUpdateCommandOfStringMessage : OutboundWebSocketMessage<GroupUpdate<string>> +{ + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfStringMessage"/> class. + /// </summary> + /// <param name="data">The send command.</param> + public SyncPlayGroupUpdateCommandOfStringMessage(GroupUpdate<string> data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)] + public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs new file mode 100644 index 0000000000..f44fd126b6 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Timer cancelled message. +/// </summary> +public class TimerCancelledMessage : OutboundWebSocketMessage<TimerEventInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="TimerCancelledMessage"/> class. + /// </summary> + /// <param name="data">Timer event info.</param> + public TimerCancelledMessage(TimerEventInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.TimerCancelled)] + public override SessionMessageType MessageType => SessionMessageType.TimerCancelled; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs new file mode 100644 index 0000000000..8c1e102eb2 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Timer created message. +/// </summary> +public class TimerCreatedMessage : OutboundWebSocketMessage<TimerEventInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="TimerCreatedMessage"/> class. + /// </summary> + /// <param name="data">Timer event info.</param> + public TimerCreatedMessage(TimerEventInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.TimerCreated)] + public override SessionMessageType MessageType => SessionMessageType.TimerCreated; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs new file mode 100644 index 0000000000..6a053643d8 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// User data changed message. +/// </summary> +public class UserDataChangedMessage : OutboundWebSocketMessage<UserDataChangeInfo> +{ + /// <summary> + /// Initializes a new instance of the <see cref="UserDataChangedMessage"/> class. + /// </summary> + /// <param name="data">The data change info.</param> + public UserDataChangedMessage(UserDataChangeInfo data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.UserDataChanged)] + public override SessionMessageType MessageType => SessionMessageType.UserDataChanged; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs new file mode 100644 index 0000000000..add3f77717 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// User deleted message. +/// </summary> +public class UserDeletedMessage : OutboundWebSocketMessage<Guid> +{ + /// <summary> + /// Initializes a new instance of the <see cref="UserDeletedMessage"/> class. + /// </summary> + /// <param name="data">The user id.</param> + public UserDeletedMessage(Guid data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.UserDeleted)] + public override SessionMessageType MessageType => SessionMessageType.UserDeleted; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs new file mode 100644 index 0000000000..9a72deae1f --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// User updated message. +/// </summary> +public class UserUpdatedMessage : OutboundWebSocketMessage<UserDto> +{ + /// <summary> + /// Initializes a new instance of the <see cref="UserUpdatedMessage"/> class. + /// </summary> + /// <param name="data">The user dto.</param> + public UserUpdatedMessage(UserDto data) + : base(data) + { + } + + /// <inheritdoc /> + [DefaultValue(SessionMessageType.UserUpdated)] + public override SessionMessageType MessageType => SessionMessageType.UserUpdated; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs new file mode 100644 index 0000000000..ad97796e70 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs @@ -0,0 +1,14 @@ +using System; + +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Outbound websocket message. +/// </summary> +public class OutboundWebSocketMessage : WebSocketMessage, IOutboundWebSocketMessage +{ + /// <summary> + /// Gets or sets the message id. + /// </summary> + public Guid MessageId { get; set; } +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessageOfT.cs b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessageOfT.cs new file mode 100644 index 0000000000..f09f294b41 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessageOfT.cs @@ -0,0 +1,33 @@ +#pragma warning disable SA1649 // File name must equal class name. + +using System; + +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Outbound websocket message with data. +/// </summary> +/// <typeparam name="T">The data type.</typeparam> +public class OutboundWebSocketMessage<T> : WebSocketMessage<T>, IOutboundWebSocketMessage +{ + /// <summary> + /// Initializes a new instance of the <see cref="OutboundWebSocketMessage{T}"/> class. + /// </summary> + public OutboundWebSocketMessage() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="OutboundWebSocketMessage{T}"/> class. + /// </summary> + /// <param name="data">The data to send.</param> + protected OutboundWebSocketMessage(T data) + { + Data = data; + } + + /// <summary> + /// Gets or sets the message id. + /// </summary> + public Guid MessageId { get; set; } +} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 24f7b5cd36..2c52b2b45e 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -28,7 +28,7 @@ namespace MediaBrowser.Controller.Persistence /// </summary> /// <param name="items">The items.</param> /// <param name="cancellationToken">The cancellation token.</param> - void SaveItems(IEnumerable<BaseItem> items, CancellationToken cancellationToken); + void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken); void SaveImages(BaseItem item); diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index f6c5920709..d1a51c2cf6 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -56,5 +56,19 @@ namespace MediaBrowser.Controller.Playlists /// <param name="newIndex">The new index.</param> /// <returns>Task.</returns> Task MoveItemAsync(string playlistId, string entryId, int newIndex); + + /// <summary> + /// Removed all playlists of a user. + /// If the playlist is shared, ownership is transferred. + /// </summary> + /// <param name="userId">The user id.</param> + /// <returns>Task.</returns> + Task RemovePlaylistsAsync(Guid userId); + + /// <summary> + /// Saves a playlist. + /// </summary> + /// <param name="item">The playlist.</param> + void SavePlaylistFile(Playlist item); } } diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index e6bcc9ea85..498df5ab06 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -15,6 +15,7 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Playlists @@ -33,10 +34,13 @@ namespace MediaBrowser.Controller.Playlists public Playlist() { Shares = Array.Empty<Share>(); + OpenAccess = false; } public Guid OwnerUserId { get; set; } + public bool OpenAccess { get; set; } + public Share[] Shares { get; set; } [JsonIgnore] @@ -232,7 +236,13 @@ namespace MediaBrowser.Controller.Playlists return base.IsVisible(user); } - if (user.Id.Equals(OwnerUserId)) + if (OpenAccess) + { + return true; + } + + var userId = user.Id; + if (userId.Equals(OwnerUserId)) { return true; } @@ -240,10 +250,9 @@ namespace MediaBrowser.Controller.Playlists var shares = Shares; if (shares.Length == 0) { - return base.IsVisible(user); + return false; } - var userId = user.Id; return shares.Any(share => Guid.TryParse(share.UserId, out var id) && id.Equals(userId)); } diff --git a/MediaBrowser.Controller/Providers/EpisodeInfo.cs b/MediaBrowser.Controller/Providers/EpisodeInfo.cs index b59a037384..c4ad352a3b 100644 --- a/MediaBrowser.Controller/Providers/EpisodeInfo.cs +++ b/MediaBrowser.Controller/Providers/EpisodeInfo.cs @@ -12,10 +12,13 @@ namespace MediaBrowser.Controller.Providers public EpisodeInfo() { SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + SeasonProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); } public Dictionary<string, string> SeriesProviderIds { get; set; } + public Dictionary<string, string> SeasonProviderIds { get; set; } + public int? IndexNumberEnd { get; set; } public bool IsMissingEpisode { get; set; } diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 7e0a69586c..16943f6aaa 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -55,14 +55,6 @@ namespace MediaBrowser.Controller.Providers Task<ItemUpdateType> RefreshSingleItem(BaseItem item, MetadataRefreshOptions options, CancellationToken cancellationToken); /// <summary> - /// Runs multiple metadata refreshes concurrently. - /// </summary> - /// <param name="action">The action to run.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns> - Task RunMetadataRefresh(Func<Task> action, CancellationToken cancellationToken); - - /// <summary> /// Saves the image. /// </summary> /// <param name="item">The item.</param> @@ -207,15 +199,6 @@ namespace MediaBrowser.Controller.Providers where TItemType : BaseItem, new() where TLookupType : ItemLookupInfo; - /// <summary> - /// Gets the search image. - /// </summary> - /// <param name="providerName">Name of the provider.</param> - /// <param name="url">The URL.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{HttpResponseInfo}.</returns> - Task<HttpResponseMessage> GetSearchImage(string providerName, string url, CancellationToken cancellationToken); - HashSet<Guid> GetRefreshQueue(); void OnRefreshStart(BaseItem item); diff --git a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs index fd73ed5f80..05b4d43a5a 100644 --- a/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/ImageRefreshOptions.cs @@ -1,6 +1,7 @@ #pragma warning disable CA1819, CS1591 using System; +using System.Collections.Generic; using System.Linq; using MediaBrowser.Model.Entities; @@ -23,7 +24,7 @@ namespace MediaBrowser.Controller.Providers public bool ReplaceAllImages { get; set; } - public ImageType[] ReplaceImages { get; set; } + public IReadOnlyList<ImageType> ReplaceImages { get; set; } public bool IsAutomated { get; set; } diff --git a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs index 8a37094620..9e91a8bcd7 100644 --- a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs @@ -26,6 +26,7 @@ namespace MediaBrowser.Controller.Providers ReplaceAllMetadata = copy.ReplaceAllMetadata; EnableRemoteContentProbe = copy.EnableRemoteContentProbe; + IsAutomated = copy.IsAutomated; ImageRefreshMode = copy.ImageRefreshMode; ReplaceAllImages = copy.ReplaceAllImages; ReplaceImages = copy.ReplaceImages; diff --git a/MediaBrowser.Controller/Session/ISessionController.cs b/MediaBrowser.Controller/Session/ISessionController.cs index b38ee11462..c8b29aa1f3 100644 --- a/MediaBrowser.Controller/Session/ISessionController.cs +++ b/MediaBrowser.Controller/Session/ISessionController.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index eefc5d222e..0c4719a0e5 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities.Security; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Session; diff --git a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs index 52aa44024d..fcfc18a644 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs @@ -1,9 +1,6 @@ -#nullable disable - #pragma warning disable CS1591 using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -20,12 +17,6 @@ namespace MediaBrowser.Controller.Subtitles event EventHandler<SubtitleDownloadFailureEventArgs> SubtitleDownloadFailure; /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="subtitleProviders">The subtitle providers.</param> - void AddParts(IEnumerable<ISubtitleProvider> subtitleProviders); - - /// <summary> /// Searches the subtitles. /// </summary> /// <param name="video">The video.</param> diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs index 2164945560..dcc06db1ed 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -533,11 +533,9 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates _logger.LogWarning("Session {SessionId} is seeking to wrong position, correcting.", session.Id); return; } - else - { - // Session is ready. - context.SetBuffering(session, false); - } + + // Session is ready. + context.SetBuffering(session, false); if (!context.IsBuffering()) { diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs index ddbfeb8de8..c0a168192e 100644 --- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -23,13 +23,13 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// The sorted playlist. /// </summary> /// <value>The sorted playlist, or play queue of the group.</value> - private List<QueueItem> _sortedPlaylist = new List<QueueItem>(); + private List<SyncPlayQueueItem> _sortedPlaylist = new List<SyncPlayQueueItem>(); /// <summary> /// The shuffled playlist. /// </summary> /// <value>The shuffled playlist, or play queue of the group.</value> - private List<QueueItem> _shuffledPlaylist = new List<QueueItem>(); + private List<SyncPlayQueueItem> _shuffledPlaylist = new List<SyncPlayQueueItem>(); /// <summary> /// Initializes a new instance of the <see cref="PlayQueueManager" /> class. @@ -76,7 +76,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// Gets the current playlist considering the shuffle mode. /// </summary> /// <returns>The playlist.</returns> - public IReadOnlyList<QueueItem> GetPlaylist() + public IReadOnlyList<SyncPlayQueueItem> GetPlaylist() { return GetPlaylistInternal(); } @@ -93,7 +93,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue _sortedPlaylist = CreateQueueItemsFromArray(items); if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { - _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist); + _shuffledPlaylist = new List<SyncPlayQueueItem>(_sortedPlaylist); _shuffledPlaylist.Shuffle(); } @@ -125,14 +125,14 @@ namespace MediaBrowser.Controller.SyncPlay.Queue { if (PlayingItemIndex == NoPlayingItemIndex) { - _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist); + _shuffledPlaylist = new List<SyncPlayQueueItem>(_sortedPlaylist); _shuffledPlaylist.Shuffle(); } else if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) { // First time shuffle. var playingItem = _sortedPlaylist[PlayingItemIndex]; - _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist); + _shuffledPlaylist = new List<SyncPlayQueueItem>(_sortedPlaylist); _shuffledPlaylist.RemoveAt(PlayingItemIndex); _shuffledPlaylist.Shuffle(); _shuffledPlaylist.Insert(0, playingItem); @@ -313,17 +313,13 @@ namespace MediaBrowser.Controller.SyncPlay.Queue return true; } - else - { - // Restoring playing item. - SetPlayingItemByPlaylistId(playingItem.PlaylistItemId); - return false; - } - } - else - { + + // Restoring playing item. + SetPlayingItemByPlaylistId(playingItem.PlaylistItemId); return false; } + + return false; } /// <summary> @@ -411,7 +407,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// Gets the next item in the playlist considering repeat mode and shuffle mode. /// </summary> /// <returns>The next item in the playlist.</returns> - public QueueItem GetNextItemPlaylistId() + public SyncPlayQueueItem GetNextItemPlaylistId() { int newIndex; var playlist = GetPlaylistInternal(); @@ -506,12 +502,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// Creates a list from the array of items. Each item is given an unique playlist identifier. /// </summary> /// <returns>The list of queue items.</returns> - private List<QueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items) + private List<SyncPlayQueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items) { - var list = new List<QueueItem>(); + var list = new List<SyncPlayQueueItem>(); foreach (var item in items) { - var queueItem = new QueueItem(item); + var queueItem = new SyncPlayQueueItem(item); list.Add(queueItem); } @@ -522,36 +518,33 @@ namespace MediaBrowser.Controller.SyncPlay.Queue /// Gets the current playlist considering the shuffle mode. /// </summary> /// <returns>The playlist.</returns> - private List<QueueItem> GetPlaylistInternal() + private List<SyncPlayQueueItem> GetPlaylistInternal() { if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { return _shuffledPlaylist; } - else - { - return _sortedPlaylist; - } + + return _sortedPlaylist; } /// <summary> /// Gets the current playing item, depending on the shuffle mode. /// </summary> /// <returns>The playing item.</returns> - private QueueItem GetPlayingItem() + private SyncPlayQueueItem GetPlayingItem() { if (PlayingItemIndex == NoPlayingItemIndex) { return null; } - else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) { return _shuffledPlaylist[PlayingItemIndex]; } - else - { - return _sortedPlaylist[PlayingItemIndex]; - } + + return _sortedPlaylist[PlayingItemIndex]; } } } diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index 039127f9e3..71cdea5298 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -22,13 +22,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index 1030cf0558..cb369d8377 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -6,8 +6,10 @@ using System.Linq; using System.Text; using System.Threading; using System.Xml; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -71,10 +73,7 @@ namespace MediaBrowser.LocalMetadata.Parsers foreach (var info in idInfos) { var id = info.Key + "Id"; - if (!_validProviderIds.ContainsKey(id)) - { - _validProviderIds.Add(id, info.Key); - } + _validProviderIds.TryAdd(id, info.Key); } // Additional Mappings @@ -169,12 +168,9 @@ namespace MediaBrowser.LocalMetadata.Parsers { var text = reader.ReadElementContentAsString(); - if (!string.IsNullOrEmpty(text)) + if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) { - if (float.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) - { - item.CriticRating = value; - } + item.CriticRating = value; } break; @@ -373,7 +369,7 @@ namespace MediaBrowser.LocalMetadata.Parsers case "Director": { - foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Director })) + foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Director })) { if (string.IsNullOrWhiteSpace(p.Name)) { @@ -388,7 +384,7 @@ namespace MediaBrowser.LocalMetadata.Parsers case "Writer": { - foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Writer })) + foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Writer })) { if (string.IsNullOrWhiteSpace(p.Name)) { @@ -415,7 +411,7 @@ namespace MediaBrowser.LocalMetadata.Parsers else { // Old-style piped string - foreach (var p in SplitNames(actors).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Actor })) + foreach (var p in SplitNames(actors).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Actor })) { if (string.IsNullOrWhiteSpace(p.Name)) { @@ -431,7 +427,7 @@ namespace MediaBrowser.LocalMetadata.Parsers case "GuestStars": { - foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.GuestStar })) + foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.GuestStar })) { if (string.IsNullOrWhiteSpace(p.Name)) { @@ -639,6 +635,21 @@ namespace MediaBrowser.LocalMetadata.Parsers break; } + case "OwnerUserId": + { + var val = reader.ReadElementContentAsString(); + + if (Guid.TryParse(val, out var guid) && !guid.Equals(Guid.Empty)) + { + if (item is Playlist playlist) + { + playlist.OwnerUserId = guid; + } + } + + break; + } + case "Format3D": { var val = reader.ReadElementContentAsString(); @@ -1038,7 +1049,7 @@ namespace MediaBrowser.LocalMetadata.Parsers private IEnumerable<PersonInfo> GetPersonsFromXmlNode(XmlReader reader) { var name = string.Empty; - var type = PersonType.Actor; // If type is not specified assume actor + var type = PersonKind.Actor; // If type is not specified assume actor var role = string.Empty; int? sortOrder = null; @@ -1059,11 +1070,7 @@ namespace MediaBrowser.LocalMetadata.Parsers case "Type": { var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - type = val; - } + _ = Enum.TryParse(val, true, out type); break; } diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index d92b504740..f913b2320d 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -374,7 +374,7 @@ namespace MediaBrowser.LocalMetadata.Savers { await writer.WriteStartElementAsync(null, "Person", null).ConfigureAwait(false); await writer.WriteElementStringAsync(null, "Name", null, person.Name).ConfigureAwait(false); - await writer.WriteElementStringAsync(null, "Type", null, person.Type).ConfigureAwait(false); + await writer.WriteElementStringAsync(null, "Type", null, person.Type.ToString()).ConfigureAwait(false); await writer.WriteElementStringAsync(null, "Role", null, person.Role).ConfigureAwait(false); if (person.SortOrder.HasValue) @@ -395,6 +395,7 @@ namespace MediaBrowser.LocalMetadata.Savers if (item is Playlist playlist && !Playlist.IsPlaylistFile(playlist.Path)) { + await writer.WriteElementStringAsync(null, "OwnerUserId", null, playlist.OwnerUserId.ToString("N")).ConfigureAwait(false); await AddLinkedChildren(playlist, writer, "PlaylistItems", "PlaylistItem").ConfigureAwait(false); } @@ -418,16 +419,19 @@ namespace MediaBrowser.LocalMetadata.Savers foreach (var share in item.Shares) { - await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false); + if (share.UserId is not null) + { + await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false); - await writer.WriteElementStringAsync(null, "UserId", null, share.UserId).ConfigureAwait(false); - await writer.WriteElementStringAsync( - null, - "CanEdit", - null, - share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false); + await writer.WriteElementStringAsync(null, "UserId", null, share.UserId).ConfigureAwait(false); + await writer.WriteElementStringAsync( + null, + "CanEdit", + null, + share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false); - await writer.WriteEndElementAsync().ConfigureAwait(false); + await writer.WriteEndElementAsync().ConfigureAwait(false); + } } await writer.WriteEndElementAsync().ConfigureAwait(false); diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index db177ff769..989e386a51 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -14,6 +14,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -230,10 +231,8 @@ namespace MediaBrowser.MediaEncoding.Attachments throw new InvalidOperationException( string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath)); } - else - { - _logger.LogInformation("ffmpeg attachment extraction completed for {Path} to {Path}", inputPath, outputPath); - } + + _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } private async Task<Stream> GetAttachmentStream( @@ -301,10 +300,10 @@ namespace MediaBrowser.MediaEncoding.Attachments var processArgs = string.Format( CultureInfo.InvariantCulture, - "-dump_attachment:{1} {2} -i {0} -t 0 -f null null", + "-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null", inputPath, attachmentStreamIndex, - outputPath); + EncodingUtils.NormalizePath(outputPath)); int exitCode; @@ -375,10 +374,8 @@ namespace MediaBrowser.MediaEncoding.Attachments throw new InvalidOperationException( string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputPath)); } - else - { - _logger.LogInformation("ffmpeg attachment extraction completed for {Path} to {Path}", inputPath, outputPath); - } + + _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); } private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex) diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs new file mode 100644 index 0000000000..fca17d4c05 --- /dev/null +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs @@ -0,0 +1,122 @@ +using System.IO; +using System.Linq; +using BDInfo.IO; +using MediaBrowser.Model.IO; + +namespace MediaBrowser.MediaEncoding.BdInfo; + +/// <summary> +/// Class BdInfoDirectoryInfo. +/// </summary> +public class BdInfoDirectoryInfo : IDirectoryInfo +{ + private readonly IFileSystem _fileSystem; + + private readonly FileSystemMetadata _impl; + + /// <summary> + /// Initializes a new instance of the <see cref="BdInfoDirectoryInfo" /> class. + /// </summary> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="path">The path.</param> + public BdInfoDirectoryInfo(IFileSystem fileSystem, string path) + { + _fileSystem = fileSystem; + _impl = _fileSystem.GetDirectoryInfo(path); + } + + private BdInfoDirectoryInfo(IFileSystem fileSystem, FileSystemMetadata impl) + { + _fileSystem = fileSystem; + _impl = impl; + } + + /// <summary> + /// Gets the name. + /// </summary> + public string Name => _impl.Name; + + /// <summary> + /// Gets the full name. + /// </summary> + public string FullName => _impl.FullName; + + /// <summary> + /// Gets the parent directory information. + /// </summary> + public IDirectoryInfo? Parent + { + get + { + var parentFolder = Path.GetDirectoryName(_impl.FullName); + if (parentFolder is not null) + { + return new BdInfoDirectoryInfo(_fileSystem, parentFolder); + } + + return null; + } + } + + /// <summary> + /// Gets the directories. + /// </summary> + /// <returns>An array with all directories.</returns> + public IDirectoryInfo[] GetDirectories() + { + return _fileSystem.GetDirectories(_impl.FullName) + .Select(x => new BdInfoDirectoryInfo(_fileSystem, x)) + .ToArray(); + } + + /// <summary> + /// Gets the files. + /// </summary> + /// <returns>All files of the directory.</returns> + public IFileInfo[] GetFiles() + { + return _fileSystem.GetFiles(_impl.FullName) + .Select(x => new BdInfoFileInfo(x)) + .ToArray(); + } + + /// <summary> + /// Gets the files matching a pattern. + /// </summary> + /// <param name="searchPattern">The search pattern.</param> + /// <returns>All files of the directory matchign the search pattern.</returns> + public IFileInfo[] GetFiles(string searchPattern) + { + return _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false) + .Select(x => new BdInfoFileInfo(x)) + .ToArray(); + } + + /// <summary> + /// Gets the files matching a pattern and search options. + /// </summary> + /// <param name="searchPattern">The search pattern.</param> + /// <param name="searchOption">The search optin.</param> + /// <returns>All files of the directory matchign the search pattern and options.</returns> + public IFileInfo[] GetFiles(string searchPattern, SearchOption searchOption) + { + return _fileSystem.GetFiles( + _impl.FullName, + new[] { searchPattern }, + false, + searchOption == SearchOption.AllDirectories) + .Select(x => new BdInfoFileInfo(x)) + .ToArray(); + } + + /// <summary> + /// Gets the bdinfo of a file system path. + /// </summary> + /// <param name="fs">The file system.</param> + /// <param name="path">The path.</param> + /// <returns>The BD directory information of the path on the file system.</returns> + public static IDirectoryInfo FromFileSystemPath(IFileSystem fs, string path) + { + return new BdInfoDirectoryInfo(fs, path); + } +} diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs new file mode 100644 index 0000000000..8ebb59c59e --- /dev/null +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BDInfo; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; + +namespace MediaBrowser.MediaEncoding.BdInfo; + +/// <summary> +/// Class BdInfoExaminer. +/// </summary> +public class BdInfoExaminer : IBlurayExaminer +{ + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="BdInfoExaminer" /> class. + /// </summary> + /// <param name="fileSystem">The filesystem.</param> + public BdInfoExaminer(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + /// <summary> + /// Gets the disc info. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>BlurayDiscInfo.</returns> + public BlurayDiscInfo GetDiscInfo(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentNullException(nameof(path)); + } + + var bdrom = new BDROM(BdInfoDirectoryInfo.FromFileSystemPath(_fileSystem, path)); + + bdrom.Scan(); + + // Get the longest playlist + var playlist = bdrom.PlaylistFiles.Values.OrderByDescending(p => p.TotalLength).FirstOrDefault(p => p.IsValid); + + var outputStream = new BlurayDiscInfo + { + MediaStreams = Array.Empty<MediaStream>() + }; + + if (playlist is null) + { + return outputStream; + } + + outputStream.Chapters = playlist.Chapters.ToArray(); + + outputStream.RunTimeTicks = TimeSpan.FromSeconds(playlist.TotalLength).Ticks; + + var sortedStreams = playlist.SortedStreams; + var mediaStreams = new List<MediaStream>(sortedStreams.Count); + + foreach (var stream in sortedStreams) + { + switch (stream) + { + case TSVideoStream videoStream: + AddVideoStream(mediaStreams, videoStream); + break; + case TSAudioStream audioStream: + AddAudioStream(mediaStreams, audioStream); + break; + case TSTextStream textStream: + AddSubtitleStream(mediaStreams, textStream); + break; + case TSGraphicsStream graphicStream: + AddSubtitleStream(mediaStreams, graphicStream); + break; + } + } + + outputStream.MediaStreams = mediaStreams.ToArray(); + + outputStream.PlaylistName = playlist.Name; + + if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0) + { + // Get the files in the playlist + outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.Name).ToArray(); + } + + return outputStream; + } + + /// <summary> + /// Adds the video stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="videoStream">The video stream.</param> + private void AddVideoStream(List<MediaStream> streams, TSVideoStream videoStream) + { + var mediaStream = new MediaStream + { + BitRate = Convert.ToInt32(videoStream.BitRate), + Width = videoStream.Width, + Height = videoStream.Height, + Codec = videoStream.CodecShortName, + IsInterlaced = videoStream.IsInterlaced, + Type = MediaStreamType.Video, + Index = streams.Count + }; + + if (videoStream.FrameRateDenominator > 0) + { + float frameRateEnumerator = videoStream.FrameRateEnumerator; + float frameRateDenominator = videoStream.FrameRateDenominator; + + mediaStream.AverageFrameRate = mediaStream.RealFrameRate = frameRateEnumerator / frameRateDenominator; + } + + streams.Add(mediaStream); + } + + /// <summary> + /// Adds the audio stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="audioStream">The audio stream.</param> + private void AddAudioStream(List<MediaStream> streams, TSAudioStream audioStream) + { + var stream = new MediaStream + { + Codec = audioStream.CodecShortName, + Language = audioStream.LanguageCode, + Channels = audioStream.ChannelCount, + SampleRate = audioStream.SampleRate, + Type = MediaStreamType.Audio, + Index = streams.Count + }; + + var bitrate = Convert.ToInt32(audioStream.BitRate); + + if (bitrate > 0) + { + stream.BitRate = bitrate; + } + + if (audioStream.LFE > 0) + { + stream.Channels = audioStream.ChannelCount + 1; + } + + streams.Add(stream); + } + + /// <summary> + /// Adds the subtitle stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="textStream">The text stream.</param> + private void AddSubtitleStream(List<MediaStream> streams, TSTextStream textStream) + { + streams.Add(new MediaStream + { + Language = textStream.LanguageCode, + Codec = textStream.CodecShortName, + Type = MediaStreamType.Subtitle, + Index = streams.Count + }); + } + + /// <summary> + /// Adds the subtitle stream. + /// </summary> + /// <param name="streams">The streams.</param> + /// <param name="textStream">The text stream.</param> + private void AddSubtitleStream(List<MediaStream> streams, TSGraphicsStream textStream) + { + streams.Add(new MediaStream + { + Language = textStream.LanguageCode, + Codec = textStream.CodecShortName, + Type = MediaStreamType.Subtitle, + Index = streams.Count + }); + } +} diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs new file mode 100644 index 0000000000..9e7a1d50a3 --- /dev/null +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs @@ -0,0 +1,68 @@ +using System.IO; +using MediaBrowser.Model.IO; + +namespace MediaBrowser.MediaEncoding.BdInfo; + +/// <summary> +/// Class BdInfoFileInfo. +/// </summary> +public class BdInfoFileInfo : BDInfo.IO.IFileInfo +{ + private FileSystemMetadata _impl; + + /// <summary> + /// Initializes a new instance of the <see cref="BdInfoFileInfo" /> class. + /// </summary> + /// <param name="impl">The <see cref="FileSystemMetadata" />.</param> + public BdInfoFileInfo(FileSystemMetadata impl) + { + _impl = impl; + } + + /// <summary> + /// Gets the name. + /// </summary> + public string Name => _impl.Name; + + /// <summary> + /// Gets the full name. + /// </summary> + public string FullName => _impl.FullName; + + /// <summary> + /// Gets the extension. + /// </summary> + public string Extension => _impl.Extension; + + /// <summary> + /// Gets the length. + /// </summary> + public long Length => _impl.Length; + + /// <summary> + /// Gets a value indicating whether this is a directory. + /// </summary> + public bool IsDir => _impl.IsDirectory; + + /// <summary> + /// Gets a file as file stream. + /// </summary> + /// <returns>A <see cref="FileStream" /> for the file.</returns> + public Stream OpenRead() + { + return new FileStream( + FullName, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + } + + /// <summary> + /// Gets a files's content with a stream reader. + /// </summary> + /// <returns>A <see cref="StreamReader" /> for the file's content.</returns> + public StreamReader OpenText() + { + return new StreamReader(OpenRead()); + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 8479b7d50d..e1a0e8d670 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -25,11 +25,12 @@ namespace MediaBrowser.MediaEncoding.Encoder "mpeg2video", "mpeg4", "msmpeg4", - "dts", + "dca", "ac3", "aac", "mp3", "flac", + "truehd", "h264_qsv", "hevc_qsv", "mpeg2_qsv", @@ -51,26 +52,34 @@ namespace MediaBrowser.MediaEncoding.Encoder { "libx264", "libx265", + "libsvtav1", "mpeg4", "msmpeg4", "libvpx", "libvpx-vp9", "aac", + "aac_at", "libfdk_aac", "ac3", + "dca", "libmp3lame", "libopus", "libvorbis", "flac", + "truehd", "srt", "h264_amf", "hevc_amf", + "av1_amf", "h264_qsv", "hevc_qsv", + "av1_qsv", "h264_nvenc", "hevc_nvenc", + "av1_nvenc", "h264_vaapi", "hevc_vaapi", + "av1_vaapi", "h264_v4l2m2m", "h264_videotoolbox", "hevc_videotoolbox" @@ -106,7 +115,10 @@ namespace MediaBrowser.MediaEncoding.Encoder // vulkan "libplacebo", "scale_vulkan", - "overlay_vulkan" + "overlay_vulkan", + "hwupload_vaapi", + // videotoolbox + "yadif_videotoolbox" }; private static readonly IReadOnlyDictionary<int, string[]> _filterOptionsDict = new Dictionary<int, string[]> @@ -210,12 +222,14 @@ namespace MediaBrowser.MediaEncoding.Encoder return false; } - else if (version < MinVersion) // Version is below what we recommend + + if (version < MinVersion) // Version is below what we recommend { _logger.LogWarning("FFmpeg validation: The minimum recommended version is {MinVersion}", MinVersion); return false; } - else if (MaxVersion is not null && version > MaxVersion) // Version is above what we recommend + + if (MaxVersion is not null && version > MaxVersion) // Version is above what we recommend { _logger.LogWarning("FFmpeg validation: The maximum recommended version is {MaxVersion}", MaxVersion); return false; @@ -273,7 +287,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (match.Success) { - if (Version.TryParse(match.Groups[1].Value, out var result)) + if (Version.TryParse(match.Groups[1].ValueSpan, out var result)) { return result; } @@ -323,8 +337,8 @@ namespace MediaBrowser.MediaEncoding.Encoder RegexOptions.Multiline)) { var version = new Version( - int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture), - int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture)); + int.Parse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture), + int.Parse(match.Groups["minor"].ValueSpan, CultureInfo.InvariantCulture)); map.Add(match.Groups["name"].Value, version); } @@ -484,7 +498,6 @@ namespace MediaBrowser.MediaEncoding.Encoder var found = Regex .Matches(output, @"^\s\S{6}\s(?<codec>[\w|-]+)\s+.+$", RegexOptions.Multiline) - .Cast<Match>() .Select(x => x.Groups["codec"].Value) .Where(x => required.Contains(x)); @@ -513,7 +526,6 @@ namespace MediaBrowser.MediaEncoding.Encoder var found = Regex .Matches(output, @"^\s\S{3}\s(?<filter>[\w|-]+)\s+.+$", RegexOptions.Multiline) - .Cast<Match>() .Select(x => x.Groups["filter"].Value) .Where(x => _requiredFilters.Contains(x)); diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs index d0ea0429b5..04128c9119 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs @@ -1,7 +1,9 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using System.Globalization; +using System.Linq; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.MediaEncoding.Encoder @@ -15,21 +17,38 @@ namespace MediaBrowser.MediaEncoding.Encoder return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", inputFile); } - return GetConcatInputArgument(inputFile, inputPrefix); + return GetFileInputArgument(inputFile, inputPrefix); + } + + public static string GetInputArgument(string inputPrefix, IReadOnlyList<string> inputFiles, MediaProtocol protocol) + { + if (protocol != MediaProtocol.File) + { + return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", inputFiles[0]); + } + + return GetConcatInputArgument(inputFiles, inputPrefix); } /// <summary> /// Gets the concat input argument. /// </summary> - /// <param name="inputFile">The input file.</param> + /// <param name="inputFiles">The input files.</param> /// <param name="inputPrefix">The input prefix.</param> /// <returns>System.String.</returns> - private static string GetConcatInputArgument(string inputFile, string inputPrefix) + private static string GetConcatInputArgument(IReadOnlyList<string> inputFiles, string inputPrefix) { // Get all streams // If there's more than one we'll need to use the concat command + if (inputFiles.Count > 1) + { + var files = string.Join("|", inputFiles.Select(NormalizePath)); + + return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files); + } + // Determine the input path for video files - return GetFileInputArgument(inputFile, inputPrefix); + return GetFileInputArgument(inputFiles[0], inputPrefix); } /// <summary> @@ -56,7 +75,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// </summary> /// <param name="path">The path.</param> /// <returns>System.String.</returns> - private static string NormalizePath(string path) + public static string NormalizePath(string path) { // Quotes are valid path characters in linux and they need to be escaped here with a leading \ return path.Replace("\"", "\\\"", StringComparison.Ordinal); diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index d2240b5aff..4e63d205c9 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -11,7 +11,9 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -50,11 +52,12 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; private readonly ILocalizationManager _localization; + private readonly IBlurayExaminer _blurayExaminer; private readonly IConfiguration _config; private readonly IServerConfigurationManager _serverConfig; private readonly string _startupOptionFFmpegPath; - private readonly SemaphoreSlim _thumbnailResourcePool = new SemaphoreSlim(2, 2); + private readonly SemaphoreSlim _thumbnailResourcePool; private readonly object _runningProcessesLock = new object(); private readonly List<ProcessWrapper> _runningProcesses = new List<ProcessWrapper>(); @@ -94,6 +97,7 @@ namespace MediaBrowser.MediaEncoding.Encoder ILogger<MediaEncoder> logger, IServerConfigurationManager configurationManager, IFileSystem fileSystem, + IBlurayExaminer blurayExaminer, ILocalizationManager localization, IConfiguration config, IServerConfigurationManager serverConfig) @@ -101,11 +105,17 @@ namespace MediaBrowser.MediaEncoding.Encoder _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; + _blurayExaminer = blurayExaminer; _localization = localization; _config = config; _serverConfig = serverConfig; _startupOptionFFmpegPath = config.GetValue<string>(Controller.Extensions.ConfigurationExtensions.FfmpegPathKey) ?? string.Empty; - _jsonSerializerOptions = JsonDefaults.Options; + + _jsonSerializerOptions = new JsonSerializerOptions(JsonDefaults.Options); + _jsonSerializerOptions.Converters.Add(new JsonBoolStringConverter()); + + var semaphoreCount = 2 * Environment.ProcessorCount; + _thumbnailResourcePool = new SemaphoreSlim(semaphoreCount, semaphoreCount); } /// <inheritdoc /> @@ -114,16 +124,22 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <inheritdoc /> public string ProbePath => _ffprobePath; + /// <inheritdoc /> public Version EncoderVersion => _ffmpegVersion; + /// <inheritdoc /> public bool IsPkeyPauseSupported => _isPkeyPauseSupported; + /// <inheritdoc /> public bool IsVaapiDeviceAmd => _isVaapiDeviceAmd; + /// <inheritdoc /> public bool IsVaapiDeviceInteliHD => _isVaapiDeviceInteliHD; + /// <inheritdoc /> public bool IsVaapiDeviceInteli965 => _isVaapiDeviceInteli965; + /// <inheritdoc /> public bool IsVaapiDeviceSupportVulkanFmtModifier => _isVaapiDeviceSupportVulkanFmtModifier; /// <summary> @@ -341,26 +357,31 @@ namespace MediaBrowser.MediaEncoding.Encoder _ffmpegVersion = validator.GetFFmpegVersion(); } + /// <inheritdoc /> public bool SupportsEncoder(string encoder) { return _encoders.Contains(encoder, StringComparer.OrdinalIgnoreCase); } + /// <inheritdoc /> public bool SupportsDecoder(string decoder) { return _decoders.Contains(decoder, StringComparer.OrdinalIgnoreCase); } + /// <inheritdoc /> public bool SupportsHwaccel(string hwaccel) { return _hwaccels.Contains(hwaccel, StringComparer.OrdinalIgnoreCase); } + /// <inheritdoc /> public bool SupportsFilter(string filter) { return _filters.Contains(filter, StringComparer.OrdinalIgnoreCase); } + /// <inheritdoc /> public bool SupportsFilterWithOption(FilterOptionType option) { if (_filtersWithOption.TryGetValue((int)option, out var val)) @@ -391,24 +412,16 @@ namespace MediaBrowser.MediaEncoding.Encoder return true; } - /// <summary> - /// Gets the media info. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> + /// <inheritdoc /> public Task<MediaInfo> GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken) { var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters; - var inputFile = request.MediaSource.Path; - string analyzeDuration = string.Empty; string ffmpegAnalyzeDuration = _config.GetFFmpegAnalyzeDuration() ?? string.Empty; if (request.MediaSource.AnalyzeDurationMs > 0) { - analyzeDuration = "-analyzeduration " + - (request.MediaSource.AnalyzeDurationMs * 1000).ToString(); + analyzeDuration = "-analyzeduration " + (request.MediaSource.AnalyzeDurationMs * 1000).ToString(); } else if (!string.IsNullOrEmpty(ffmpegAnalyzeDuration)) { @@ -416,7 +429,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } return GetMediaInfoInternal( - GetInputArgument(inputFile, request.MediaSource), + GetInputArgument(request.MediaSource.Path, request.MediaSource), request.MediaSource.Path, request.MediaSource.Protocol, extractChapters, @@ -426,36 +439,30 @@ namespace MediaBrowser.MediaEncoding.Encoder cancellationToken); } - /// <summary> - /// Gets the input argument. - /// </summary> - /// <param name="inputFile">The input file.</param> - /// <param name="mediaSource">The mediaSource.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentException">Unrecognized InputType.</exception> + /// <inheritdoc /> + public string GetInputArgument(IReadOnlyList<string> inputFiles, MediaSourceInfo mediaSource) + { + return EncodingUtils.GetInputArgument("file", inputFiles, mediaSource.Protocol); + } + + /// <inheritdoc /> public string GetInputArgument(string inputFile, MediaSourceInfo mediaSource) { var prefix = "file"; - if (mediaSource.VideoType == VideoType.BluRay - || mediaSource.IsoType == IsoType.BluRay) + if (mediaSource.IsoType == IsoType.BluRay) { prefix = "bluray"; } - return EncodingUtils.GetInputArgument(prefix, inputFile, mediaSource.Protocol); + return EncodingUtils.GetInputArgument(prefix, new[] { inputFile }, mediaSource.Protocol); } - /// <summary> - /// Gets the input argument for an external subtitle file. - /// </summary> - /// <param name="inputFile">The input file.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentException">Unrecognized InputType.</exception> + /// <inheritdoc /> public string GetExternalSubtitleInputArgument(string inputFile) { const string Prefix = "file"; - return EncodingUtils.GetInputArgument(Prefix, inputFile, MediaProtocol.File); + return EncodingUtils.GetInputArgument(Prefix, new[] { inputFile }, MediaProtocol.File); } /// <summary> @@ -546,6 +553,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + /// <inheritdoc /> public Task<string> ExtractAudioImage(string path, int? imageStreamIndex, CancellationToken cancellationToken) { var mediaSource = new MediaSourceInfo @@ -556,11 +564,13 @@ namespace MediaBrowser.MediaEncoding.Encoder return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, ImageFormat.Jpg, cancellationToken); } + /// <inheritdoc /> public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken) { return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, ImageFormat.Jpg, cancellationToken); } + /// <inheritdoc /> public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken) { return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, targetFormat, cancellationToken); @@ -764,6 +774,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + /// <inheritdoc /> public string GetTimeParameter(long ticks) { var time = TimeSpan.FromTicks(ticks); @@ -862,6 +873,114 @@ namespace MediaBrowser.MediaEncoding.Encoder throw new NotImplementedException(); } + /// <inheritdoc /> + public IReadOnlyList<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber) + { + // Eliminate menus and intros by omitting VIDEO_TS.VOB and all subsequent title .vob files ending with _0.VOB + var allVobs = _fileSystem.GetFiles(path, true) + .Where(file => string.Equals(file.Extension, ".VOB", StringComparison.OrdinalIgnoreCase)) + .Where(file => !string.Equals(file.Name, "VIDEO_TS.VOB", StringComparison.OrdinalIgnoreCase)) + .Where(file => !file.Name.EndsWith("_0.VOB", StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.FullName) + .ToList(); + + if (titleNumber.HasValue) + { + var prefix = string.Format(CultureInfo.InvariantCulture, "VTS_{0:D2}_", titleNumber.Value); + var vobs = allVobs.Where(i => i.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (vobs.Count > 0) + { + return vobs.Select(i => i.FullName).ToList(); + } + + _logger.LogWarning("Could not determine .vob files for title {Title} of {Path}.", titleNumber, path); + } + + // Check for multiple big titles (> 900 MB) + var titles = allVobs + .Where(vob => vob.Length >= 900 * 1024 * 1024) + .Select(vob => _fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString()) + .Distinct() + .ToList(); + + // Fall back to first title if no big title is found + if (titles.Count == 0) + { + titles.Add(_fileSystem.GetFileNameWithoutExtension(allVobs[0]).AsSpan().RightPart('_').ToString()); + } + + // Aggregate all .vob files of the titles + return allVobs + .Where(vob => titles.Contains(_fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString())) + .Select(i => i.FullName) + .ToList(); + } + + /// <inheritdoc /> + public IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path) + { + // Get all playable .m2ts files + var validPlaybackFiles = _blurayExaminer.GetDiscInfo(path).Files; + + // Get all files from the BDMV/STREAMING directory + var directoryFiles = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM")); + + // Only return playable local .m2ts files + return directoryFiles + .Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase)) + .Select(f => f.FullName) + .ToList(); + } + + /// <inheritdoc /> + public void GenerateConcatConfig(MediaSourceInfo source, string concatFilePath) + { + // Get all playable files + IReadOnlyList<string> files; + var videoType = source.VideoType; + if (videoType == VideoType.Dvd) + { + files = GetPrimaryPlaylistVobFiles(source.Path, null); + } + else if (videoType == VideoType.BluRay) + { + files = GetPrimaryPlaylistM2tsFiles(source.Path); + } + else + { + return; + } + + // Generate concat configuration entries for each file and write to file + using (StreamWriter sw = new StreamWriter(concatFilePath)) + { + foreach (var path in files) + { + var mediaInfoResult = GetMediaInfo( + new MediaInfoRequest + { + MediaType = DlnaProfileType.Video, + MediaSource = new MediaSourceInfo + { + Path = path, + Protocol = MediaProtocol.File, + VideoType = videoType + } + }, + CancellationToken.None).GetAwaiter().GetResult(); + + var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds; + + // Add file path stanza to concat configuration + sw.WriteLine("file '{0}'", path); + + // Add duration stanza to concat configuration + sw.WriteLine("duration {0}", duration); + } + } + } + public bool CanExtractSubtitles(string codec) { // TODO is there ever a case when a subtitle can't be extracted?? diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 93177298f9..a0624fe76b 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -22,21 +22,22 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="libse" Version="3.6.10" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" /> - <PackageReference Include="System.Text.Encoding.CodePages" Version="7.0.0" /> - <PackageReference Include="UTF.Unknown" Version="2.5.1" /> + <PackageReference Include="BDInfo" /> + <PackageReference Include="libse" /> + <PackageReference Include="Microsoft.Extensions.Http" /> + <PackageReference Include="System.Text.Encoding.CodePages" /> + <PackageReference Include="UTF.Unknown" /> </ItemGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/MediaBrowser.MediaEncoding/Probing/CodecType.cs b/MediaBrowser.MediaEncoding/Probing/CodecType.cs new file mode 100644 index 0000000000..d7c68e5f38 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Probing/CodecType.cs @@ -0,0 +1,32 @@ +namespace MediaBrowser.MediaEncoding.Probing; + +/// <summary> +/// FFmpeg Codec Type. +/// </summary> +public enum CodecType +{ + /// <summary> + /// Video. + /// </summary> + Video, + + /// <summary> + /// Audio. + /// </summary> + Audio, + + /// <summary> + /// Opaque data information usually continuous. + /// </summary> + Data, + + /// <summary> + /// Subtitles. + /// </summary> + Subtitle, + + /// <summary> + /// Opaque data information usually sparse. + /// </summary> + Attachment +} diff --git a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs index eab8f79bb3..2944423248 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs @@ -43,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// </summary> /// <value>The codec_type.</value> [JsonPropertyName("codec_type")] - public string CodecType { get; set; } + public CodecType CodecType { get; set; } /// <summary> /// Gets or sets the sample_rate. @@ -228,11 +228,11 @@ namespace MediaBrowser.MediaEncoding.Probing public long StartPts { get; set; } /// <summary> - /// Gets or sets the is_avc. + /// Gets or sets a value indicating whether the stream is AVC. /// </summary> /// <value>The is_avc.</value> [JsonPropertyName("is_avc")] - public string IsAvc { get; set; } + public bool IsAvc { get; set; } /// <summary> /// Gets or sets the nal_length_size. diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index c667f5f57c..7d655240b7 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Xml; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; @@ -67,6 +68,9 @@ namespace MediaBrowser.MediaEncoding.Probing "諭吉佳作/men", "//dARTH nULL", "Phantom/Ghost", + "She/Her/Hers", + "5/8erl in Ehr'n", + "Smith/Kotzen", }; public MediaInfo GetMediaInfo(InternalMediaInfoResult data, VideoType? videoType, bool isAudio, string path, MediaProtocol protocol) @@ -97,19 +101,16 @@ namespace MediaBrowser.MediaEncoding.Probing { info.Container = NormalizeFormat(data.Format.FormatName); - if (!string.IsNullOrEmpty(data.Format.BitRate)) + if (int.TryParse(data.Format.BitRate, CultureInfo.InvariantCulture, out var value)) { - if (int.TryParse(data.Format.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) - { - info.Bitrate = value; - } + info.Bitrate = value; } } var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - var tagStreamType = isAudio ? "audio" : "video"; + var tagStreamType = isAudio ? CodecType.Audio : CodecType.Video; - var tagStream = data.Streams?.FirstOrDefault(i => string.Equals(i.CodecType, tagStreamType, StringComparison.OrdinalIgnoreCase)); + var tagStream = data.Streams?.FirstOrDefault(i => i.CodecType == tagStreamType); if (tagStream?.Tags is not null) { @@ -251,12 +252,23 @@ namespace MediaBrowser.MediaEncoding.Probing return null; } + // Handle MPEG-1 container if (string.Equals(format, "mpegvideo", StringComparison.OrdinalIgnoreCase)) { return "mpeg"; } - format = format.Replace("matroska", "mkv", StringComparison.OrdinalIgnoreCase); + // Handle MPEG-2 container + if (string.Equals(format, "mpeg", StringComparison.OrdinalIgnoreCase)) + { + return "ts"; + } + + // Handle matroska container + if (string.Equals(format, "matroska", StringComparison.OrdinalIgnoreCase)) + { + return "mkv"; + } return format; } @@ -499,7 +511,7 @@ namespace MediaBrowser.MediaEncoding.Probing peoples.Add(new BaseItemPerson { Name = pair.Value, - Type = PersonType.Writer + Type = PersonKind.Writer }); } } @@ -510,7 +522,7 @@ namespace MediaBrowser.MediaEncoding.Probing peoples.Add(new BaseItemPerson { Name = pair.Value, - Type = PersonType.Producer + Type = PersonKind.Producer }); } } @@ -521,7 +533,7 @@ namespace MediaBrowser.MediaEncoding.Probing peoples.Add(new BaseItemPerson { Name = pair.Value, - Type = PersonType.Director + Type = PersonKind.Director }); } } @@ -561,8 +573,8 @@ namespace MediaBrowser.MediaEncoding.Probing } } - if (string.IsNullOrWhiteSpace(name) || - string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(name) + || string.IsNullOrWhiteSpace(value)) { return null; } @@ -599,7 +611,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// <returns>MediaAttachments.</returns> private MediaAttachment GetMediaAttachment(MediaStreamInfo streamInfo) { - if (!string.Equals(streamInfo.CodecType, "attachment", StringComparison.OrdinalIgnoreCase) + if (streamInfo.CodecType != CodecType.Attachment && streamInfo.Disposition?.GetValueOrDefault("attached_pic") != 1) { return null; @@ -651,20 +663,10 @@ namespace MediaBrowser.MediaEncoding.Probing PixelFormat = streamInfo.PixelFormat, NalLengthSize = streamInfo.NalLengthSize, TimeBase = streamInfo.TimeBase, - CodecTimeBase = streamInfo.CodecTimeBase + CodecTimeBase = streamInfo.CodecTimeBase, + IsAVC = streamInfo.IsAvc }; - if (string.Equals(streamInfo.IsAvc, "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(streamInfo.IsAvc, "1", StringComparison.OrdinalIgnoreCase)) - { - stream.IsAVC = true; - } - else if (string.Equals(streamInfo.IsAvc, "false", StringComparison.OrdinalIgnoreCase) || - string.Equals(streamInfo.IsAvc, "0", StringComparison.OrdinalIgnoreCase)) - { - stream.IsAVC = false; - } - // Filter out junk if (!string.IsNullOrWhiteSpace(streamInfo.CodecTagString) && !streamInfo.CodecTagString.Contains("[0]", StringComparison.OrdinalIgnoreCase)) { @@ -678,18 +680,15 @@ namespace MediaBrowser.MediaEncoding.Probing stream.Title = GetDictionaryValue(streamInfo.Tags, "title"); } - if (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase)) + if (streamInfo.CodecType == CodecType.Audio) { stream.Type = MediaStreamType.Audio; stream.Channels = streamInfo.Channels; - if (!string.IsNullOrEmpty(streamInfo.SampleRate)) + if (int.TryParse(streamInfo.SampleRate, CultureInfo.InvariantCulture, out var sampleRate)) { - if (int.TryParse(streamInfo.SampleRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) - { - stream.SampleRate = value; - } + stream.SampleRate = sampleRate; } stream.ChannelLayout = ParseChannelLayout(streamInfo.ChannelLayout); @@ -713,7 +712,7 @@ namespace MediaBrowser.MediaEncoding.Probing } } } - else if (string.Equals(streamInfo.CodecType, "subtitle", StringComparison.OrdinalIgnoreCase)) + else if (streamInfo.CodecType == CodecType.Subtitle) { stream.Type = MediaStreamType.Subtitle; stream.Codec = NormalizeSubtitleCodec(stream.Codec); @@ -733,7 +732,7 @@ namespace MediaBrowser.MediaEncoding.Probing } } } - else if (string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)) + else if (streamInfo.CodecType == CodecType.Video) { stream.AverageFrameRate = GetFrameRate(streamInfo.AverageFrameRate); stream.RealFrameRate = GetFrameRate(streamInfo.RFrameRate); @@ -854,35 +853,30 @@ namespace MediaBrowser.MediaEncoding.Probing } } } - else if (string.Equals(streamInfo.CodecType, "data", StringComparison.OrdinalIgnoreCase)) + else if (streamInfo.CodecType == CodecType.Data) { stream.Type = MediaStreamType.Data; } else { - _logger.LogError("Codec Type {CodecType} unknown. The stream (index: {Index}) will be ignored. Warning: Subsequential streams will have a wrong stream specifier!", streamInfo.CodecType, streamInfo.Index); return null; } // Get stream bitrate var bitrate = 0; - if (!string.IsNullOrEmpty(streamInfo.BitRate)) + if (int.TryParse(streamInfo.BitRate, CultureInfo.InvariantCulture, out var value)) { - if (int.TryParse(streamInfo.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) - { - bitrate = value; - } + bitrate = value; } // The bitrate info of FLAC musics and some videos is included in formatInfo. if (bitrate == 0 && formatInfo is not null - && !string.IsNullOrEmpty(formatInfo.BitRate) && (stream.Type == MediaStreamType.Video || (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, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) + if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value)) { bitrate = value; } @@ -895,29 +889,26 @@ namespace MediaBrowser.MediaEncoding.Probing // Extract bitrate info from tag "BPS" if possible. if (!stream.BitRate.HasValue - && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase) - || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase))) + && (streamInfo.CodecType == CodecType.Audio + || streamInfo.CodecType == CodecType.Video)) { var bps = GetBPSFromTags(streamInfo); if (bps > 0) { stream.BitRate = bps; } - } - - // Get average bitrate info from tag "NUMBER_OF_BYTES" and "DURATION" if possible. - if (!stream.BitRate.HasValue - && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase) - || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase))) - { - var durationInSeconds = GetRuntimeSecondsFromTags(streamInfo); - var bytes = GetNumberOfBytesFromTags(streamInfo); - if (durationInSeconds is not null && bytes is not null) + else { - var bps = Convert.ToInt32(bytes * 8 / durationInSeconds, CultureInfo.InvariantCulture); - if (bps > 0) + // Get average bitrate info from tag "NUMBER_OF_BYTES" and "DURATION" if possible. + var durationInSeconds = GetRuntimeSecondsFromTags(streamInfo); + var bytes = GetNumberOfBytesFromTags(streamInfo); + if (durationInSeconds is not null && bytes is not null) { - stream.BitRate = bps; + bps = Convert.ToInt32(bytes * 8 / durationInSeconds, CultureInfo.InvariantCulture); + if (bps > 0) + { + stream.BitRate = bps; + } } } } @@ -948,12 +939,8 @@ namespace MediaBrowser.MediaEncoding.Probing private void NormalizeStreamTitle(MediaStream stream) { - if (string.Equals(stream.Title, "cc", StringComparison.OrdinalIgnoreCase)) - { - stream.Title = null; - } - - if (stream.Type == MediaStreamType.EmbeddedImage) + if (string.Equals(stream.Title, "cc", StringComparison.OrdinalIgnoreCase) + || stream.Type == MediaStreamType.EmbeddedImage) { stream.Title = null; } @@ -984,7 +971,7 @@ namespace MediaBrowser.MediaEncoding.Probing return null; } - return input.Split('(').FirstOrDefault(); + return input.AsSpan().LeftPart('(').ToString(); } private string GetAspectRatio(MediaStreamInfo info) @@ -992,11 +979,11 @@ namespace MediaBrowser.MediaEncoding.Probing var original = info.DisplayAspectRatio; var parts = (original ?? string.Empty).Split(':'); - if (!(parts.Length == 2 && - int.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var width) && - int.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var height) && - width > 0 && - height > 0)) + if (!(parts.Length == 2 + && int.TryParse(parts[0], CultureInfo.InvariantCulture, out var width) + && int.TryParse(parts[1], CultureInfo.InvariantCulture, out var height) + && width > 0 + && height > 0)) { width = info.Width; height = info.Height; @@ -1077,12 +1064,6 @@ namespace MediaBrowser.MediaEncoding.Probing int index = value.IndexOf('/'); if (index == -1) { - // REVIEW: is this branch actually required? (i.e. does ffprobe ever output something other than a fraction?) - if (float.TryParse(value, NumberStyles.AllowThousands | NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - return null; } @@ -1098,7 +1079,7 @@ namespace MediaBrowser.MediaEncoding.Probing private void SetAudioRuntimeTicks(InternalMediaInfoResult result, MediaInfo data) { // Get the first info stream - var stream = result.Streams?.FirstOrDefault(s => string.Equals(s.CodecType, "audio", StringComparison.OrdinalIgnoreCase)); + var stream = result.Streams?.FirstOrDefault(s => s.CodecType == CodecType.Audio); if (stream is null) { return; @@ -1128,8 +1109,7 @@ namespace MediaBrowser.MediaEncoding.Probing } var bps = GetDictionaryValue(streamInfo.Tags, "BPS-eng") ?? GetDictionaryValue(streamInfo.Tags, "BPS"); - if (!string.IsNullOrEmpty(bps) - && int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps)) + if (int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps)) { return parsedBps; } @@ -1145,7 +1125,7 @@ namespace MediaBrowser.MediaEncoding.Probing } var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); - if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration)) + if (TimeSpan.TryParse(duration, out var parsedDuration)) { return parsedDuration.TotalSeconds; } @@ -1162,8 +1142,7 @@ namespace MediaBrowser.MediaEncoding.Probing var numberOfBytes = GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES-eng") ?? GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES"); - if (!string.IsNullOrEmpty(numberOfBytes) - && long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes)) + if (long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes)) { return parsedBytes; } @@ -1188,7 +1167,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(composer, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Composer }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Composer }); } } @@ -1196,7 +1175,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(conductor, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Conductor }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Conductor }); } } @@ -1204,7 +1183,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(lyricist, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Lyricist }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Lyricist }); } } @@ -1220,7 +1199,7 @@ namespace MediaBrowser.MediaEncoding.Probing people.Add(new BaseItemPerson { Name = match.Groups["name"].Value, - Type = PersonType.Actor, + Type = PersonKind.Actor, Role = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(match.Groups["instrument"].Value) }); } @@ -1232,7 +1211,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(writer, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Writer }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Writer }); } } @@ -1240,7 +1219,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(arranger, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Arranger }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Arranger }); } } @@ -1248,7 +1227,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(engineer, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Engineer }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Engineer }); } } @@ -1256,7 +1235,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(mixer, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Mixer }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Mixer }); } } @@ -1264,7 +1243,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(remixer, false)) { - people.Add(new BaseItemPerson { Name = person, Type = PersonType.Remixer }); + people.Add(new BaseItemPerson { Name = person, Type = PersonKind.Remixer }); } } @@ -1455,7 +1434,7 @@ namespace MediaBrowser.MediaEncoding.Probing { var disc = tags.GetValueOrDefault(tagName); - if (!string.IsNullOrEmpty(disc) && int.TryParse(disc.AsSpan().LeftPart('/'), out var discNum)) + if (int.TryParse(disc.AsSpan().LeftPart('/'), out var discNum)) { return discNum; } @@ -1475,7 +1454,7 @@ namespace MediaBrowser.MediaEncoding.Probing // Limit accuracy to milliseconds to match xml saving var secondsString = chapter.StartTime; - if (double.TryParse(secondsString, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) + if (double.TryParse(secondsString, CultureInfo.InvariantCulture, out var seconds)) { var ms = Math.Round(TimeSpan.FromSeconds(seconds).TotalMilliseconds); info.StartPositionTicks = TimeSpan.FromMilliseconds(ms).Ticks; @@ -1516,7 +1495,7 @@ namespace MediaBrowser.MediaEncoding.Probing { video.People = people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries) .Where(i => !string.IsNullOrWhiteSpace(i)) - .Select(i => new BaseItemPerson { Name = i.Trim(), Type = PersonType.Actor }) + .Select(i => new BaseItemPerson { Name = i.Trim(), Type = PersonKind.Actor }) .ToArray(); } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 90bc491322..794906c3b4 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -449,7 +449,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { try { - _logger.LogInformation("Deleting converted subtitle due to failure: ", outputPath); + _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath); _fileSystem.DeleteFile(outputPath); } catch (IOException ex) @@ -624,10 +624,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new FfmpegException( string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath)); } - else - { - _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); - } + + _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase)) { diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 0ff95a2e1f..3f0e98ec8e 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -14,11 +14,14 @@ public class EncodingOptions public EncodingOptions() { EnableFallbackFont = false; + EnableAudioVbr = false; DownMixAudioBoost = 2; DownMixStereoAlgorithm = DownMixStereoAlgorithms.None; MaxMuxingQueueSize = 2048; EnableThrottling = false; ThrottleDelaySeconds = 180; + EnableSegmentDeletion = false; + SegmentKeepSeconds = 720; EncodingThreadCount = -1; // This is a DRM device that is almost guaranteed to be there on every intel platform, // plus it's the default one in ffmpeg if you don't specify anything @@ -26,25 +29,27 @@ public class EncodingOptions EnableTonemapping = false; EnableVppTonemapping = false; TonemappingAlgorithm = "bt2390"; + TonemappingMode = "auto"; TonemappingRange = "auto"; TonemappingDesat = 0; - TonemappingThreshold = 0.8; TonemappingPeak = 100; TonemappingParam = 0; - VppTonemappingBrightness = 0; - VppTonemappingContrast = 1.2; + VppTonemappingBrightness = 16; + VppTonemappingContrast = 1; H264Crf = 23; H265Crf = 28; DeinterlaceDoubleRate = false; DeinterlaceMethod = "yadif"; EnableDecodingColorDepth10Hevc = true; EnableDecodingColorDepth10Vp9 = true; - EnableEnhancedNvdecDecoder = false; + // Enhanced Nvdec or system native decoder is required for DoVi to SDR tone-mapping. + EnableEnhancedNvdecDecoder = true; PreferSystemNativeHwDecoder = true; EnableIntelLowPowerH264HwEncoder = false; EnableIntelLowPowerHevcHwEncoder = false; EnableHardwareEncoding = true; AllowHevcEncoding = false; + AllowAv1Encoding = false; EnableSubtitleExtraction = true; AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" }; HardwareDecodingCodecs = new string[] { "h264", "vc1" }; @@ -71,6 +76,11 @@ public class EncodingOptions public bool EnableFallbackFont { get; set; } /// <summary> + /// Gets or sets a value indicating whether audio VBR is enabled. + /// </summary> + public bool EnableAudioVbr { get; set; } + + /// <summary> /// Gets or sets the audio boost applied when downmixing audio. /// </summary> public double DownMixAudioBoost { get; set; } @@ -96,6 +106,16 @@ public class EncodingOptions public int ThrottleDelaySeconds { get; set; } /// <summary> + /// Gets or sets a value indicating whether segment deletion is enabled. + /// </summary> + public bool EnableSegmentDeletion { get; set; } + + /// <summary> + /// Gets or sets seconds for which segments should be kept before being deleted. + /// </summary> + public int SegmentKeepSeconds { get; set; } + + /// <summary> /// Gets or sets the hardware acceleration type. /// </summary> public string HardwareAccelerationType { get; set; } @@ -131,6 +151,11 @@ public class EncodingOptions public string TonemappingAlgorithm { get; set; } /// <summary> + /// Gets or sets the tone-mapping mode. + /// </summary> + public string TonemappingMode { get; set; } + + /// <summary> /// Gets or sets the tone-mapping range. /// </summary> public string TonemappingRange { get; set; } @@ -141,11 +166,6 @@ public class EncodingOptions public double TonemappingDesat { get; set; } /// <summary> - /// Gets or sets the tone-mapping threshold. - /// </summary> - public double TonemappingThreshold { get; set; } - - /// <summary> /// Gets or sets the tone-mapping peak. /// </summary> public double TonemappingPeak { get; set; } @@ -231,6 +251,11 @@ public class EncodingOptions public bool AllowHevcEncoding { get; set; } /// <summary> + /// Gets or sets a value indicating whether AV1 encoding is enabled. + /// </summary> + public bool AllowAv1Encoding { get; set; } + + /// <summary> /// Gets or sets a value indicating whether subtitle extraction is enabled. /// </summary> public bool EnableSubtitleExtraction { get; set; } diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 81f2f02bc5..df68299465 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -30,6 +30,8 @@ namespace MediaBrowser.Model.Configuration public bool EnableRealtimeMonitor { get; set; } + public bool EnableLUFSScan { get; set; } + public bool EnableChapterImageExtraction { get; set; } public bool ExtractChapterImagesDuringLibraryScan { get; set; } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index c39162250a..78a310f0b1 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -2,7 +2,6 @@ #pragma warning disable CA1819 using System; -using System.Collections.Generic; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Updates; @@ -167,6 +166,12 @@ namespace MediaBrowser.Model.Configuration public int LibraryMonitorDelay { get; set; } = 60; /// <summary> + /// Gets or sets the duration in seconds that we will wait after a library updated event before executing the library changed notification. + /// </summary> + /// <value>The library update duration.</value> + public int LibraryUpdateDuration { get; set; } = 30; + + /// <summary> /// Gets or sets the image saving convention. /// </summary> /// <value>The image saving convention.</value> @@ -184,7 +189,7 @@ namespace MediaBrowser.Model.Configuration public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>(); - public int RemoteClientBitrateLimit { get; set; } = 0; + public int RemoteClientBitrateLimit { get; set; } public bool EnableFolderView { get; set; } = false; @@ -198,7 +203,7 @@ namespace MediaBrowser.Model.Configuration public bool EnableExternalContentInSuggestions { get; set; } = true; - public int ImageExtractionTimeoutMs { get; set; } = 0; + public int ImageExtractionTimeoutMs { get; set; } public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>(); @@ -243,16 +248,10 @@ namespace MediaBrowser.Model.Configuration public bool AllowClientLogUpload { get; set; } = true; /// <summary> - /// Gets or sets the dummy chapters duration in seconds. + /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation alltogether. /// </summary> /// <value>The dummy chapters duration.</value> - public int DummyChapterDuration { get; set; } = 300; - - /// <summary> - /// Gets or sets the dummy chapter count. - /// </summary> - /// <value>The dummy chapter count.</value> - public int DummyChapterCount { get; set; } = 100; + public int DummyChapterDuration { get; set; } /// <summary> /// Gets or sets the chapter image resolution. @@ -264,6 +263,6 @@ namespace MediaBrowser.Model.Configuration /// Gets or sets the limit for parallel image encoding. /// </summary> /// <value>The limit for parallel image encoding.</value> - public int ParallelImageEncodingLimit { get; set; } = 0; + public int ParallelImageEncodingLimit { get; set; } } } diff --git a/MediaBrowser.Model/Cryptography/PasswordHash.cs b/MediaBrowser.Model/Cryptography/PasswordHash.cs index 80a30684ab..ccb361c132 100644 --- a/MediaBrowser.Model/Cryptography/PasswordHash.cs +++ b/MediaBrowser.Model/Cryptography/PasswordHash.cs @@ -80,7 +80,8 @@ namespace MediaBrowser.Model.Cryptography { throw new FormatException("Hash string must contain a valid id"); } - else if (nextSegment == -1) + + if (nextSegment == -1) { return new PasswordHash(hashString.ToString(), Array.Empty<byte>()); } diff --git a/MediaBrowser.Model/Dlna/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs index 5734224167..af0787990d 100644 --- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs +++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs @@ -1,14 +1,38 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna { + /// <summary> + /// The condition processor. + /// </summary> public static class ConditionProcessor { + /// <summary> + /// Checks if a video condition is satisfied. + /// </summary> + /// <param name="condition">The <see cref="ProfileCondition"/>.</param> + /// <param name="width">The width.</param> + /// <param name="height">The height.</param> + /// <param name="videoBitDepth">The bit depth.</param> + /// <param name="videoBitrate">The bitrate.</param> + /// <param name="videoProfile">The video profile.</param> + /// <param name="videoRangeType">The <see cref="VideoRangeType"/>.</param> + /// <param name="videoLevel">The video level.</param> + /// <param name="videoFramerate">The framerate.</param> + /// <param name="packetLength">The packet length.</param> + /// <param name="timestamp">The <see cref="TransportStreamTimestamp"/>.</param> + /// <param name="isAnamorphic">A value indicating whether tthe video is anamorphic.</param> + /// <param name="isInterlaced">A value indicating whether tthe video is interlaced.</param> + /// <param name="refFrames">The reference frames.</param> + /// <param name="numVideoStreams">The number of video streams.</param> + /// <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> + /// <returns><b>True</b> if the condition is satisfied.</returns> public static bool IsVideoConditionSatisfied( ProfileCondition condition, int? width, @@ -16,7 +40,7 @@ namespace MediaBrowser.Model.Dlna int? videoBitDepth, int? videoBitrate, string? videoProfile, - string? videoRangeType, + VideoRangeType? videoRangeType, double? videoLevel, float? videoFramerate, int? packetLength, @@ -70,6 +94,13 @@ namespace MediaBrowser.Model.Dlna } } + /// <summary> + /// Checks if a image condition is satisfied. + /// </summary> + /// <param name="condition">The <see cref="ProfileCondition"/>.</param> + /// <param name="width">The width.</param> + /// <param name="height">The height.</param> + /// <returns><b>True</b> if the condition is satisfied.</returns> public static bool IsImageConditionSatisfied(ProfileCondition condition, int? width, int? height) { switch (condition.Property) @@ -83,6 +114,15 @@ namespace MediaBrowser.Model.Dlna } } + /// <summary> + /// Checks if an audio condition is satisfied. + /// </summary> + /// <param name="condition">The <see cref="ProfileCondition"/>.</param> + /// <param name="audioChannels">The channel count.</param> + /// <param name="audioBitrate">The bitrate.</param> + /// <param name="audioSampleRate">The sample rate.</param> + /// <param name="audioBitDepth">The bit depth.</param> + /// <returns><b>True</b> if the condition is satisfied.</returns> public static bool IsAudioConditionSatisfied(ProfileCondition condition, int? audioChannels, int? audioBitrate, int? audioSampleRate, int? audioBitDepth) { switch (condition.Property) @@ -100,6 +140,17 @@ namespace MediaBrowser.Model.Dlna } } + /// <summary> + /// Checks if an audio condition is satisfied for a video. + /// </summary> + /// <param name="condition">The <see cref="ProfileCondition"/>.</param> + /// <param name="audioChannels">The channel count.</param> + /// <param name="audioBitrate">The bitrate.</param> + /// <param name="audioSampleRate">The sample rate.</param> + /// <param name="audioBitDepth">The bit depth.</param> + /// <param name="audioProfile">The profile.</param> + /// <param name="isSecondaryTrack">A value indicating whether the audio is a secondary track.</param> + /// <returns><b>True</b> if the condition is satisfied.</returns> public static bool IsVideoAudioConditionSatisfied( ProfileCondition condition, int? audioChannels, @@ -136,12 +187,26 @@ namespace MediaBrowser.Model.Dlna return !condition.IsRequired; } - if (int.TryParse(condition.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var expected)) + var conditionType = condition.Condition; + if (condition.Condition == ProfileConditionType.EqualsAny) { - switch (condition.Condition) + foreach (var singleConditionString in condition.Value.AsSpan().Split('|')) + { + if (int.TryParse(singleConditionString, NumberStyles.Integer, CultureInfo.InvariantCulture, out int conditionValue) + && conditionValue.Equals(currentValue)) + { + return true; + } + } + + return false; + } + + if (int.TryParse(condition.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expected)) + { + switch (conditionType) { case ProfileConditionType.Equals: - case ProfileConditionType.EqualsAny: return currentValue.Value.Equals(expected); case ProfileConditionType.GreaterThanEqual: return currentValue.Value >= expected; @@ -212,9 +277,24 @@ namespace MediaBrowser.Model.Dlna return !condition.IsRequired; } - if (double.TryParse(condition.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var expected)) + var conditionType = condition.Condition; + if (condition.Condition == ProfileConditionType.EqualsAny) { - switch (condition.Condition) + foreach (var singleConditionString in condition.Value.AsSpan().Split('|')) + { + if (double.TryParse(singleConditionString, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double conditionValue) + && conditionValue.Equals(currentValue)) + { + return true; + } + } + + return false; + } + + if (double.TryParse(condition.Value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var expected)) + { + switch (conditionType) { case ProfileConditionType.Equals: return currentValue.Value.Equals(expected); @@ -252,5 +332,41 @@ namespace MediaBrowser.Model.Dlna throw new InvalidOperationException("Unexpected ProfileConditionType: " + condition.Condition); } } + + private static bool IsConditionSatisfied(ProfileCondition condition, VideoRangeType? currentValue) + { + if (!currentValue.HasValue || currentValue.Equals(VideoRangeType.Unknown)) + { + // If the value is unknown, it satisfies if not marked as required + return !condition.IsRequired; + } + + var conditionType = condition.Condition; + if (conditionType == ProfileConditionType.EqualsAny) + { + foreach (var singleConditionString in condition.Value.AsSpan().Split('|')) + { + if (Enum.TryParse(singleConditionString, true, out VideoRangeType conditionValue) + && conditionValue.Equals(currentValue)) + { + return true; + } + } + + return false; + } + + if (Enum.TryParse(condition.Value, true, out VideoRangeType expected)) + { + return conditionType switch + { + ProfileConditionType.Equals => currentValue.Value == expected, + ProfileConditionType.NotEquals => currentValue.Value != expected, + _ => throw new InvalidOperationException("Unexpected ProfileConditionType: " + condition.Condition) + }; + } + + return false; + } } } diff --git a/MediaBrowser.Model/Dlna/ContainerProfile.cs b/MediaBrowser.Model/Dlna/ContainerProfile.cs index 927df8e4ef..9780042684 100644 --- a/MediaBrowser.Model/Dlna/ContainerProfile.cs +++ b/MediaBrowser.Model/Dlna/ContainerProfile.cs @@ -11,7 +11,7 @@ namespace MediaBrowser.Model.Dlna [XmlAttribute("type")] public DlnaProfileType Type { get; set; } - public ProfileCondition[]? Conditions { get; set; } = Array.Empty<ProfileCondition>(); + public ProfileCondition[] Conditions { get; set; } = Array.Empty<ProfileCondition>(); [XmlAttribute("container")] public string Container { get; set; } = string.Empty; diff --git a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs index 1d5d0b1de3..f29022b54e 100644 --- a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs +++ b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using Jellyfin.Data.Enums; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna @@ -128,7 +129,7 @@ namespace MediaBrowser.Model.Dlna bool isDirectStream, long? runtimeTicks, string videoProfile, - string videoRangeType, + VideoRangeType videoRangeType, double? videoLevel, float? videoFramerate, int? packetLength, diff --git a/MediaBrowser.Model/Dlna/DeviceProfile.cs b/MediaBrowser.Model/Dlna/DeviceProfile.cs index 79ae951708..b7c23669df 100644 --- a/MediaBrowser.Model/Dlna/DeviceProfile.cs +++ b/MediaBrowser.Model/Dlna/DeviceProfile.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using System.Xml.Serialization; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; @@ -445,7 +446,7 @@ namespace MediaBrowser.Model.Dlna int? bitDepth, int? videoBitrate, string videoProfile, - string videoRangeType, + VideoRangeType videoRangeType, double? videoLevel, float? videoFramerate, int? packetLength, diff --git a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs index 03c3a72657..f68235d869 100644 --- a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs +++ b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs @@ -18,17 +18,17 @@ namespace MediaBrowser.Model.Dlna [XmlAttribute("type")] public DlnaProfileType Type { get; set; } - public bool SupportsContainer(string container) + public bool SupportsContainer(string? container) { return ContainerProfile.ContainsContainer(Container, container); } - public bool SupportsVideoCodec(string codec) + public bool SupportsVideoCodec(string? codec) { return Type == DlnaProfileType.Video && ContainerProfile.ContainsContainer(VideoCodec, codec); } - public bool SupportsAudioCodec(string codec) + public bool SupportsAudioCodec(string? codec) { return (Type == DlnaProfileType.Audio || Type == DlnaProfileType.Video) && ContainerProfile.ContainsContainer(AudioCodec, codec); } diff --git a/MediaBrowser.Model/Dlna/ITranscoderSupport.cs b/MediaBrowser.Model/Dlna/ITranscoderSupport.cs index a70ce44cc6..d7397399dd 100644 --- a/MediaBrowser.Model/Dlna/ITranscoderSupport.cs +++ b/MediaBrowser.Model/Dlna/ITranscoderSupport.cs @@ -10,22 +10,4 @@ namespace MediaBrowser.Model.Dlna bool CanExtractSubtitles(string codec); } - - public class FullTranscoderSupport : ITranscoderSupport - { - public bool CanEncodeToAudioCodec(string codec) - { - return true; - } - - public bool CanEncodeToSubtitleCodec(string codec) - { - return true; - } - - public bool CanExtractSubtitles(string codec) - { - return true; - } - } } diff --git a/MediaBrowser.Model/Dlna/MediaOptions.cs b/MediaBrowser.Model/Dlna/MediaOptions.cs index 29aecf97fc..eca971e95e 100644 --- a/MediaBrowser.Model/Dlna/MediaOptions.cs +++ b/MediaBrowser.Model/Dlna/MediaOptions.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using MediaBrowser.Model.Dto; @@ -59,22 +57,22 @@ namespace MediaBrowser.Model.Dlna /// <summary> /// Gets or sets the media sources. /// </summary> - public MediaSourceInfo[] MediaSources { get; set; } + public MediaSourceInfo[] MediaSources { get; set; } = Array.Empty<MediaSourceInfo>(); /// <summary> /// Gets or sets the device profile. /// </summary> - public DeviceProfile Profile { get; set; } + public required DeviceProfile Profile { get; set; } /// <summary> /// Gets or sets a media source id. Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested. /// </summary> - public string MediaSourceId { get; set; } + public string? MediaSourceId { get; set; } /// <summary> /// Gets or sets the device id. /// </summary> - public string DeviceId { get; set; } + public string? DeviceId { get; set; } /// <summary> /// Gets or sets an override of supported number of audio channels diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs index ce422a2288..5d7daa81aa 100644 --- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs +++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs @@ -73,27 +73,5 @@ namespace MediaBrowser.Model.Dlna return null; } - - private static double GetVideoBitrateScaleFactor(string codec) - { - if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) - { - return .6; - } - - return 1; - } - - public static int ScaleBitrate(int bitrate, string inputVideoCodec, string outputVideoCodec) - { - var inputScaleFactor = GetVideoBitrateScaleFactor(inputVideoCodec); - var outputScaleFactor = GetVideoBitrateScaleFactor(outputVideoCodec); - var scaleFactor = outputScaleFactor / inputScaleFactor; - var newBitrate = scaleFactor * bitrate; - - return Convert.ToInt32(newBitrate); - } } } diff --git a/MediaBrowser.Model/Dlna/SortCriteria.cs b/MediaBrowser.Model/Dlna/SortCriteria.cs index 7fef16e535..7df53c6d19 100644 --- a/MediaBrowser.Model/Dlna/SortCriteria.cs +++ b/MediaBrowser.Model/Dlna/SortCriteria.cs @@ -9,7 +9,7 @@ namespace MediaBrowser.Model.Dlna { public SortCriteria(string sortOrder) { - if (!string.IsNullOrEmpty(sortOrder) && Enum.TryParse<SortOrder>(sortOrder, true, out var sortOrderValue)) + if (Enum.TryParse<SortOrder>(sortOrder, true, out var sortOrderValue)) { SortOrder = sortOrderValue; } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 62d67c0257..f6b882c3e6 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1,9 +1,8 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; @@ -25,6 +24,9 @@ namespace MediaBrowser.Model.Dlna private readonly ILogger _logger; private readonly ITranscoderSupport _transcoderSupport; + private static readonly string[] _supportedHlsVideoCodecs = new string[] { "h264", "hevc", "av1" }; + private static readonly string[] _supportedHlsAudioCodecsTs = new string[] { "aac", "ac3", "eac3", "mp3" }; + private static readonly string[] _supportedHlsAudioCodecsMp4 = new string[] { "aac", "ac3", "eac3", "mp3", "alac", "flac", "opus", "dca", "truehd" }; /// <summary> /// Initializes a new instance of the <see cref="StreamBuilder"/> class. @@ -38,53 +40,36 @@ namespace MediaBrowser.Model.Dlna } /// <summary> - /// Initializes a new instance of the <see cref="StreamBuilder"/> class. - /// </summary> - /// <param name="logger">The <see cref="ILogger"/> object.</param> - public StreamBuilder(ILogger<StreamBuilder> logger) - : this(new FullTranscoderSupport(), logger) - { - } - - /// <summary> /// Gets the optimal audio stream. /// </summary> /// <param name="options">The <see cref="MediaOptions"/> object to get the audio stream from.</param> /// <returns>The <see cref="StreamInfo"/> of the optimal audio stream.</returns> - public StreamInfo GetOptimalAudioStream(MediaOptions options) + public StreamInfo? GetOptimalAudioStream(MediaOptions options) { ValidateMediaOptions(options, false); - var mediaSources = new List<MediaSourceInfo>(); + var streams = new List<StreamInfo>(); foreach (var mediaSource in options.MediaSources) { - if (string.IsNullOrEmpty(options.MediaSourceId) || - string.Equals(mediaSource.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase)) + if (!(string.IsNullOrEmpty(options.MediaSourceId) + || string.Equals(mediaSource.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase))) { - mediaSources.Add(mediaSource); + continue; } - } - var streams = new List<StreamInfo>(); - foreach (var mediaSourceInfo in mediaSources) - { - StreamInfo streamInfo = GetOptimalAudioStream(mediaSourceInfo, options); + StreamInfo? streamInfo = GetOptimalAudioStream(mediaSource, options); if (streamInfo is not null) { + streamInfo.DeviceId = options.DeviceId; + streamInfo.DeviceProfileId = options.Profile.Id; streams.Add(streamInfo); } } - foreach (var stream in streams) - { - stream.DeviceId = options.DeviceId; - stream.DeviceProfileId = options.Profile.Id; - } - return GetOptimalStream(streams, options.GetMaxBitrate(true) ?? 0); } - private StreamInfo GetOptimalAudioStream(MediaSourceInfo item, MediaOptions options) + private StreamInfo? GetOptimalAudioStream(MediaSourceInfo item, MediaOptions options) { var playlistItem = new StreamInfo { @@ -118,7 +103,7 @@ namespace MediaBrowser.Model.Dlna var transcodeReasons = directPlayInfo.TranscodeReasons; var inputAudioChannels = audioStream?.Channels; - var inputAudioBitrate = audioStream?.BitDepth; + var inputAudioBitrate = audioStream?.BitRate; var inputAudioSampleRate = audioStream?.SampleRate; var inputAudioBitDepth = audioStream?.BitDepth; @@ -138,12 +123,12 @@ namespace MediaBrowser.Model.Dlna } } - TranscodingProfile transcodingProfile = null; + TranscodingProfile? transcodingProfile = null; foreach (var tcProfile in options.Profile.TranscodingProfiles) { if (tcProfile.Type == playlistItem.MediaType && tcProfile.Context == options.Context - && _transcoderSupport.CanEncodeToAudioCodec(transcodingProfile.AudioCodec ?? tcProfile.Container)) + && _transcoderSupport.CanEncodeToAudioCodec(tcProfile.AudioCodec ?? tcProfile.Container)) { transcodingProfile = tcProfile; break; @@ -190,15 +175,15 @@ namespace MediaBrowser.Model.Dlna /// </summary> /// <param name="options">The <see cref="MediaOptions"/> object to get the video stream from.</param> /// <returns>The <see cref="StreamInfo"/> of the optimal video stream.</returns> - public StreamInfo GetOptimalVideoStream(MediaOptions options) + public StreamInfo? GetOptimalVideoStream(MediaOptions options) { ValidateMediaOptions(options, true); var mediaSources = new List<MediaSourceInfo>(); foreach (var mediaSourceInfo in options.MediaSources) { - if (string.IsNullOrEmpty(options.MediaSourceId) || - string.Equals(mediaSourceInfo.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(options.MediaSourceId) + || string.Equals(mediaSourceInfo.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase)) { mediaSources.Add(mediaSourceInfo); } @@ -223,7 +208,7 @@ namespace MediaBrowser.Model.Dlna return GetOptimalStream(streams, options.GetMaxBitrate(false) ?? 0); } - private static StreamInfo GetOptimalStream(List<StreamInfo> streams, long maxBitrate) + private static StreamInfo? GetOptimalStream(List<StreamInfo> streams, long maxBitrate) => SortMediaSources(streams, maxBitrate).FirstOrDefault(); private static IOrderedEnumerable<StreamInfo> SortMediaSources(List<StreamInfo> streams, long maxBitrate) @@ -366,7 +351,7 @@ namespace MediaBrowser.Model.Dlna /// <param name="type">The <see cref="DlnaProfileType"/>.</param> /// <param name="playProfile">The <see cref="DirectPlayProfile"/> object to get the video stream from.</param> /// <returns>The the normalized input container.</returns> - public static string NormalizeMediaSourceFormatIntoSingleContainer(string inputContainer, DeviceProfile profile, DlnaProfileType type, DirectPlayProfile playProfile = null) + public static string? NormalizeMediaSourceFormatIntoSingleContainer(string inputContainer, DeviceProfile? profile, DlnaProfileType type, DirectPlayProfile? playProfile = null) { if (string.IsNullOrEmpty(inputContainer)) { @@ -394,7 +379,7 @@ namespace MediaBrowser.Model.Dlna return formats[0]; } - private (DirectPlayProfile Profile, PlayMethod? PlayMethod, TranscodeReason TranscodeReasons) GetAudioDirectPlayProfile(MediaSourceInfo item, MediaStream audioStream, MediaOptions options) + private (DirectPlayProfile? Profile, PlayMethod? PlayMethod, TranscodeReason TranscodeReasons) GetAudioDirectPlayProfile(MediaSourceInfo item, MediaStream audioStream, MediaOptions options) { var directPlayProfile = options.Profile.DirectPlayProfiles .FirstOrDefault(x => x.Type == DlnaProfileType.Audio && IsAudioDirectPlaySupported(x, item, audioStream)); @@ -410,7 +395,6 @@ namespace MediaBrowser.Model.Dlna return (null, null, GetTranscodeReasonsFromDirectPlayProfile(item, null, audioStream, options.Profile.DirectPlayProfiles)); } - var playMethods = new List<PlayMethod>(); TranscodeReason transcodeReasons = 0; // The profile describes what the device supports @@ -449,7 +433,7 @@ namespace MediaBrowser.Model.Dlna return (directPlayProfile, null, transcodeReasons); } - private static TranscodeReason GetTranscodeReasonsFromDirectPlayProfile(MediaSourceInfo item, MediaStream videoStream, MediaStream audioStream, IEnumerable<DirectPlayProfile> directPlayProfiles) + private static TranscodeReason GetTranscodeReasonsFromDirectPlayProfile(MediaSourceInfo item, MediaStream? videoStream, MediaStream audioStream, IEnumerable<DirectPlayProfile> directPlayProfiles) { var mediaType = videoStream is null ? DlnaProfileType.Audio : DlnaProfileType.Video; @@ -551,8 +535,7 @@ namespace MediaBrowser.Model.Dlna } playlistItem.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; - if (!string.IsNullOrEmpty(transcodingProfile.MaxAudioChannels) - && int.TryParse(transcodingProfile.MaxAudioChannels, NumberStyles.Any, CultureInfo.InvariantCulture, out int transcodingMaxAudioChannels)) + if (int.TryParse(transcodingProfile.MaxAudioChannels, CultureInfo.InvariantCulture, out int transcodingMaxAudioChannels)) { playlistItem.TranscodingMaxAudioChannels = transcodingMaxAudioChannels; } @@ -576,7 +559,7 @@ namespace MediaBrowser.Model.Dlna } } - private static void SetStreamInfoOptionsFromDirectPlayProfile(MediaOptions options, MediaSourceInfo item, StreamInfo playlistItem, DirectPlayProfile directPlayProfile) + private static void SetStreamInfoOptionsFromDirectPlayProfile(MediaOptions options, MediaSourceInfo item, StreamInfo playlistItem, DirectPlayProfile? directPlayProfile) { var container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile); var protocol = "http"; @@ -588,7 +571,7 @@ namespace MediaBrowser.Model.Dlna playlistItem.SubProtocol = protocol; playlistItem.VideoCodecs = new[] { item.VideoStream.Codec }; - playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile.AudioCodec); + playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile?.AudioCodec); } private StreamInfo BuildVideoItem(MediaSourceInfo item, MediaOptions options) @@ -635,6 +618,12 @@ namespace MediaBrowser.Model.Dlna var isEligibleForDirectStream = options.EnableDirectStream && (options.ForceDirectStream || !bitrateLimitExceeded); TranscodeReason transcodeReasons = 0; + // Force transcode or remux for BD/DVD folders + if (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay) + { + isEligibleForDirectPlay = false; + } + if (bitrateLimitExceeded) { transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit; @@ -647,7 +636,7 @@ namespace MediaBrowser.Model.Dlna isEligibleForDirectPlay, isEligibleForDirectStream); - DirectPlayProfile directPlayProfile = null; + DirectPlayProfile? directPlayProfile = null; if (isEligibleForDirectPlay || isEligibleForDirectStream) { // See if it can be direct played @@ -678,16 +667,16 @@ namespace MediaBrowser.Model.Dlna playlistItem.AudioStreamIndex = audioStream?.Index; if (audioStream is not null) { - playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile.AudioCodec); + playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile?.AudioCodec); } SetStreamInfoOptionsFromDirectPlayProfile(options, item, playlistItem, directPlayProfile); - BuildStreamVideoItem(playlistItem, options, item, videoStream, audioStream, candidateAudioStreams, directPlayProfile.Container, directPlayProfile.VideoCodec, directPlayProfile.AudioCodec); + BuildStreamVideoItem(playlistItem, options, item, videoStream, audioStream, candidateAudioStreams, directPlayProfile?.Container, directPlayProfile?.VideoCodec, directPlayProfile?.AudioCodec); } if (subtitleStream is not null) { - var subtitleProfile = GetSubtitleProfile(item, subtitleStream, options.Profile.SubtitleProfiles, directPlay.Value, _transcoderSupport, directPlayProfile.Container, null); + var subtitleProfile = GetSubtitleProfile(item, subtitleStream, options.Profile.SubtitleProfiles, directPlay.Value, _transcoderSupport, directPlayProfile?.Container, null); playlistItem.SubtitleDeliveryMethod = subtitleProfile.Method; playlistItem.SubtitleFormat = subtitleProfile.Format; @@ -749,7 +738,14 @@ namespace MediaBrowser.Model.Dlna return playlistItem; } - private TranscodingProfile GetVideoTranscodeProfile(MediaSourceInfo item, MediaOptions options, MediaStream videoStream, MediaStream audioStream, IEnumerable<MediaStream> candidateAudioStreams, MediaStream subtitleStream, StreamInfo playlistItem) + private TranscodingProfile? GetVideoTranscodeProfile( + MediaSourceInfo item, + MediaOptions options, + MediaStream? videoStream, + MediaStream? audioStream, + IEnumerable<MediaStream> candidateAudioStreams, + MediaStream? subtitleStream, + StreamInfo playlistItem) { if (!(item.SupportsTranscoding || item.SupportsDirectStream)) { @@ -762,8 +758,8 @@ namespace MediaBrowser.Model.Dlna if (options.AllowVideoStreamCopy) { // prefer direct copy profile - float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0; - TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp; + float videoFramerate = videoStream?.AverageFrameRate ?? videoStream?.RealFrameRate ?? 0; + TransportStreamTimestamp? timestamp = videoStream == null ? TransportStreamTimestamp.None : item.Timestamp; int? numAudioStreams = item.GetStreamCount(MediaStreamType.Audio); int? numVideoStreams = item.GetStreamCount(MediaStreamType.Video); @@ -773,7 +769,7 @@ namespace MediaBrowser.Model.Dlna if (ContainerProfile.ContainsContainer(videoCodecs, item.VideoStream?.Codec)) { - var videoCodec = transcodingProfile.VideoCodec; + var videoCodec = videoStream?.Codec; var container = transcodingProfile.Container; var appliedVideoConditions = options.Profile.CodecProfiles .Where(i => i.Type == CodecType.Video && @@ -796,10 +792,26 @@ namespace MediaBrowser.Model.Dlna return transcodingProfiles.FirstOrDefault(); } - private void BuildStreamVideoItem(StreamInfo playlistItem, MediaOptions options, MediaSourceInfo item, MediaStream videoStream, MediaStream audioStream, IEnumerable<MediaStream> candidateAudioStreams, string container, string videoCodec, string audioCodec) + private void BuildStreamVideoItem( + StreamInfo playlistItem, + MediaOptions options, + MediaSourceInfo item, + MediaStream? videoStream, + MediaStream? audioStream, + IEnumerable<MediaStream> candidateAudioStreams, + string? container, + string? videoCodec, + string? audioCodec) { // Prefer matching video codecs var videoCodecs = ContainerProfile.SplitValue(videoCodec); + + // Enforce HLS video codec restrictions + if (string.Equals(playlistItem.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) + { + videoCodecs = videoCodecs.Where(codec => _supportedHlsVideoCodecs.Contains(codec)).ToArray(); + } + var directVideoCodec = ContainerProfile.ContainsContainer(videoCodecs, videoStream?.Codec) ? videoStream?.Codec : null; if (directVideoCodec is not null) { @@ -835,6 +847,20 @@ namespace MediaBrowser.Model.Dlna // Prefer matching audio codecs, could do better here var audioCodecs = ContainerProfile.SplitValue(audioCodec); + + // Enforce HLS audio codec restrictions + if (string.Equals(playlistItem.SubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(playlistItem.Container, "mp4", StringComparison.OrdinalIgnoreCase)) + { + audioCodecs = audioCodecs.Where(codec => _supportedHlsAudioCodecsMp4.Contains(codec)).ToArray(); + } + else + { + audioCodecs = audioCodecs.Where(codec => _supportedHlsAudioCodecsTs.Contains(codec)).ToArray(); + } + } + var directAudioStream = candidateAudioStreams.FirstOrDefault(stream => ContainerProfile.ContainsContainer(audioCodecs, stream.Codec)); playlistItem.AudioCodecs = audioCodecs; if (directAudioStream is not null) @@ -863,12 +889,12 @@ namespace MediaBrowser.Model.Dlna int? bitDepth = videoStream?.BitDepth; int? videoBitrate = videoStream?.BitRate; double? videoLevel = videoStream?.Level; - string videoProfile = videoStream?.Profile; - string videoRangeType = videoStream?.VideoRangeType; + string? videoProfile = videoStream?.Profile; + VideoRangeType? videoRangeType = videoStream?.VideoRangeType; float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0; bool? isAnamorphic = videoStream?.IsAnamorphic; bool? isInterlaced = videoStream?.IsInterlaced; - string videoCodecTag = videoStream?.CodecTag; + string? videoCodecTag = videoStream?.CodecTag; bool? isAvc = videoStream?.IsAVC; TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp; @@ -880,7 +906,7 @@ namespace MediaBrowser.Model.Dlna var appliedVideoConditions = options.Profile.CodecProfiles .Where(i => i.Type == CodecType.Video && - i.ContainsAnyCodec(videoCodec, container) && + i.ContainsAnyCodec(videoStream?.Codec, container) && i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc))); var isFirstAppliedCodecProfile = true; foreach (var i in appliedVideoConditions) @@ -904,15 +930,15 @@ namespace MediaBrowser.Model.Dlna playlistItem.AudioBitrate = Math.Min(playlistItem.AudioBitrate ?? audioBitrate, audioBitrate); bool? isSecondaryAudio = audioStream is null ? null : item.IsSecondaryAudio(audioStream); - int? inputAudioBitrate = audioStream is null ? null : audioStream.BitRate; - int? audioChannels = audioStream is null ? null : audioStream.Channels; - string audioProfile = audioStream is null ? null : audioStream.Profile; - int? inputAudioSampleRate = audioStream is null ? null : audioStream.SampleRate; - int? inputAudioBitDepth = audioStream is null ? null : audioStream.BitDepth; + int? inputAudioBitrate = audioStream?.BitRate; + int? audioChannels = audioStream?.Channels; + string? audioProfile = audioStream?.Profile; + int? inputAudioSampleRate = audioStream?.SampleRate; + int? inputAudioBitDepth = audioStream?.BitDepth; var appliedAudioConditions = options.Profile.CodecProfiles .Where(i => i.Type == CodecType.VideoAudio && - i.ContainsAnyCodec(audioCodec, container) && + i.ContainsAnyCodec(audioStream?.Codec, container) && i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, audioProfile, isSecondaryAudio))); isFirstAppliedCodecProfile = true; foreach (var codecProfile in appliedAudioConditions) @@ -956,7 +982,7 @@ namespace MediaBrowser.Model.Dlna playlistItem?.TranscodeReasons); } - private static int GetDefaultAudioBitrate(string audioCodec, int? audioChannels) + private static int GetDefaultAudioBitrate(string? audioCodec, int? audioChannels) { if (!string.IsNullOrEmpty(audioCodec)) { @@ -989,9 +1015,9 @@ namespace MediaBrowser.Model.Dlna return 192000; } - private static int GetAudioBitrate(long maxTotalBitrate, string[] targetAudioCodecs, MediaStream audioStream, StreamInfo item) + private static int GetAudioBitrate(long maxTotalBitrate, string[] targetAudioCodecs, MediaStream? audioStream, StreamInfo item) { - string targetAudioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; + string? targetAudioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; int? targetAudioChannels = item.GetTargetAudioChannels(targetAudioCodec); @@ -1050,31 +1076,38 @@ namespace MediaBrowser.Model.Dlna { return 128000; } - else if (totalBitrate <= 2000000) + + if (totalBitrate <= 2000000) { return 384000; } - else if (totalBitrate <= 3000000) + + if (totalBitrate <= 3000000) { return 448000; } - else if (totalBitrate <= 4000000) + + if (totalBitrate <= 4000000) { return 640000; } - else if (totalBitrate <= 5000000) + + if (totalBitrate <= 5000000) { return 768000; } - else if (totalBitrate <= 10000000) + + if (totalBitrate <= 10000000) { return 1536000; } - else if (totalBitrate <= 15000000) + + if (totalBitrate <= 15000000) { return 2304000; } - else if (totalBitrate <= 20000000) + + if (totalBitrate <= 20000000) { return 3584000; } @@ -1082,13 +1115,13 @@ namespace MediaBrowser.Model.Dlna return 7168000; } - private (DirectPlayProfile Profile, PlayMethod? PlayMethod, int? AudioStreamIndex, TranscodeReason TranscodeReasons) GetVideoDirectPlayProfile( + private (DirectPlayProfile? Profile, PlayMethod? PlayMethod, int? AudioStreamIndex, TranscodeReason TranscodeReasons) GetVideoDirectPlayProfile( MediaOptions options, MediaSourceInfo mediaSource, - MediaStream videoStream, - MediaStream audioStream, + MediaStream? videoStream, + MediaStream? audioStream, ICollection<MediaStream> candidateAudioStreams, - MediaStream subtitleStream, + MediaStream? subtitleStream, bool isEligibleForDirectPlay, bool isEligibleForDirectStream) { @@ -1111,12 +1144,12 @@ namespace MediaBrowser.Model.Dlna int? bitDepth = videoStream?.BitDepth; int? videoBitrate = videoStream?.BitRate; double? videoLevel = videoStream?.Level; - string videoProfile = videoStream?.Profile; - string videoRangeType = videoStream?.VideoRangeType; + string? videoProfile = videoStream?.Profile; + VideoRangeType? videoRangeType = videoStream?.VideoRangeType; float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0; bool? isAnamorphic = videoStream?.IsAnamorphic; bool? isInterlaced = videoStream?.IsInterlaced; - string videoCodecTag = videoStream?.CodecTag; + string? videoCodecTag = videoStream?.CodecTag; bool? isAvc = videoStream?.IsAVC; TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : mediaSource.Timestamp; @@ -1144,7 +1177,8 @@ namespace MediaBrowser.Model.Dlna profile, "VideoCodecProfile", profile.CodecProfiles - .Where(codecProfile => codecProfile.Type == CodecType.Video && codecProfile.ContainsAnyCodec(videoStream?.Codec, container) && + .Where(codecProfile => codecProfile.Type == CodecType.Video && + codecProfile.ContainsAnyCodec(videoStream?.Codec, container) && !checkVideoConditions(codecProfile.ApplyConditions).Any()) .SelectMany(codecProfile => checkVideoConditions(codecProfile.Conditions))); @@ -1204,14 +1238,14 @@ namespace MediaBrowser.Model.Dlna } // Check video codec - string videoCodec = videoStream?.Codec; + string? videoCodec = videoStream?.Codec; if (!directPlayProfile.SupportsVideoCodec(videoCodec)) { directPlayProfileReasons |= TranscodeReason.VideoCodecNotSupported; } // Check audio codec - MediaStream selectedAudioStream = null; + MediaStream? selectedAudioStream = null; if (candidateAudioStreams.Any()) { selectedAudioStream = candidateAudioStreams.FirstOrDefault(audioStream => directPlayProfile.SupportsAudioCodec(audioStream.Codec)); @@ -1332,8 +1366,8 @@ namespace MediaBrowser.Model.Dlna SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, ITranscoderSupport transcoderSupport, - string outputContainer, - string transcodingSubProtocol) + string? outputContainer, + string? transcodingSubProtocol) { if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || !string.Equals(transcodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))) { @@ -1406,7 +1440,7 @@ namespace MediaBrowser.Model.Dlna }; } - private static bool IsSubtitleEmbedSupported(string transcodingContainer) + private static bool IsSubtitleEmbedSupported(string? transcodingContainer) { if (!string.IsNullOrEmpty(transcodingContainer)) { @@ -1418,7 +1452,8 @@ namespace MediaBrowser.Model.Dlna { return false; } - else if (ContainerProfile.ContainsContainer(normalizedContainers, "mkv") + + if (ContainerProfile.ContainsContainer(normalizedContainers, "mkv") || ContainerProfile.ContainsContainer(normalizedContainers, "matroska")) { return true; @@ -1428,7 +1463,7 @@ namespace MediaBrowser.Model.Dlna return false; } - private static SubtitleProfile GetExternalSubtitleProfile(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, ITranscoderSupport transcoderSupport, bool allowConversion) + private static SubtitleProfile? GetExternalSubtitleProfile(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, ITranscoderSupport transcoderSupport, bool allowConversion) { foreach (var profile in subtitleProfiles) { @@ -1552,7 +1587,8 @@ namespace MediaBrowser.Model.Dlna bool? isSecondaryAudio) { return codecProfiles - .Where(profile => profile.Type == CodecType.VideoAudio && profile.ContainsAnyCodec(codec, container) && + .Where(profile => profile.Type == CodecType.VideoAudio && + profile.ContainsAnyCodec(codec, container) && profile.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, isSecondaryAudio))) .SelectMany(profile => profile.Conditions) .Where(condition => !ConditionProcessor.IsVideoAudioConditionSatisfied(condition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, isSecondaryAudio)); @@ -1561,7 +1597,7 @@ namespace MediaBrowser.Model.Dlna private static IEnumerable<ProfileCondition> GetProfileConditionsForAudio( IEnumerable<CodecProfile> codecProfiles, string container, - string codec, + string? codec, int? audioChannels, int? audioBitrate, int? audioSampleRate, @@ -1569,7 +1605,8 @@ namespace MediaBrowser.Model.Dlna bool checkConditions) { var conditions = codecProfiles - .Where(profile => profile.Type == CodecType.Audio && profile.ContainsAnyCodec(codec, container) && + .Where(profile => profile.Type == CodecType.Audio && + profile.ContainsAnyCodec(codec, container) && profile.ApplyConditions.All(applyCondition => ConditionProcessor.IsAudioConditionSatisfied(applyCondition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth))) .SelectMany(profile => profile.Conditions); @@ -1581,7 +1618,7 @@ namespace MediaBrowser.Model.Dlna return conditions.Where(condition => !ConditionProcessor.IsAudioConditionSatisfied(condition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth)); } - private void ApplyTranscodingConditions(StreamInfo item, IEnumerable<ProfileCondition> conditions, string qualifier, bool enableQualifiedConditions, bool enableNonQualifiedConditions) + private void ApplyTranscodingConditions(StreamInfo item, IEnumerable<ProfileCondition> conditions, string? qualifier, bool enableQualifiedConditions, bool enableNonQualifiedConditions) { foreach (ProfileCondition condition in conditions) { @@ -1607,7 +1644,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1633,7 +1670,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1669,7 +1706,7 @@ namespace MediaBrowser.Model.Dlna } } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1793,7 +1830,7 @@ namespace MediaBrowser.Model.Dlna } } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1829,7 +1866,7 @@ namespace MediaBrowser.Model.Dlna } } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1896,6 +1933,10 @@ namespace MediaBrowser.Model.Dlna { item.SetOption(qualifier, "rangetype", string.Join(',', values)); } + else if (condition.Condition == ProfileConditionType.NotEquals) + { + item.SetOption(qualifier, "rangetype", string.Join(',', Enum.GetNames(typeof(VideoRangeType)).Except(values))); + } else if (condition.Condition == ProfileConditionType.EqualsAny) { var currentValue = item.GetOption(qualifier, "rangetype"); @@ -1919,7 +1960,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1945,7 +1986,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1971,7 +2012,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (float.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -1997,7 +2038,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -2023,7 +2064,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var num)) { if (condition.Condition == ProfileConditionType.Equals) { @@ -2057,7 +2098,7 @@ namespace MediaBrowser.Model.Dlna } // Check audio codec - string audioCodec = audioStream?.Codec; + string? audioCodec = audioStream?.Codec; if (!profile.SupportsAudioCodec(audioCodec)) { return false; diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 3b55099079..00543616d1 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -107,9 +108,8 @@ namespace MediaBrowser.Model.Dlna public string MediaSourceId => MediaSource?.Id; - public bool IsDirectStream => - PlayMethod == PlayMethod.DirectStream || - PlayMethod == PlayMethod.DirectPlay; + public bool IsDirectStream => MediaSource?.VideoType is not (VideoType.Dvd or VideoType.BluRay) + && PlayMethod is PlayMethod.DirectStream or PlayMethod.DirectPlay; /// <summary> /// Gets the audio stream that will be used. @@ -282,23 +282,24 @@ namespace MediaBrowser.Model.Dlna /// <summary> /// Gets the target video range type that will be in the output stream. /// </summary> - public string TargetVideoRangeType + public VideoRangeType TargetVideoRangeType { get { if (IsDirectStream) { - return TargetVideoStream?.VideoRangeType; + return TargetVideoStream?.VideoRangeType ?? VideoRangeType.Unknown; } var targetVideoCodecs = TargetVideoCodec; var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) + if (!string.IsNullOrEmpty(videoCodec) + && Enum.TryParse(GetOption(videoCodec, "rangetype"), true, out VideoRangeType videoRangeType)) { - return GetOption(videoCodec, "rangetype"); + return videoRangeType; } - return TargetVideoStream?.VideoRangeType; + return TargetVideoStream?.VideoRangeType ?? VideoRangeType.Unknown; } } @@ -431,7 +432,7 @@ namespace MediaBrowser.Model.Dlna return totalBitrate.HasValue ? Convert.ToInt64(totalBitrate.Value * totalSeconds) : - (long?)null; + null; } return null; @@ -922,12 +923,8 @@ namespace MediaBrowser.Model.Dlna public int? GetTargetVideoBitDepth(string codec) { var value = GetOption(codec, "videobitdepth"); - if (string.IsNullOrEmpty(value)) - { - return null; - } - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } @@ -938,12 +935,8 @@ namespace MediaBrowser.Model.Dlna public int? GetTargetAudioBitDepth(string codec) { var value = GetOption(codec, "audiobitdepth"); - if (string.IsNullOrEmpty(value)) - { - return null; - } - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } @@ -954,12 +947,8 @@ namespace MediaBrowser.Model.Dlna public double? GetTargetVideoLevel(string codec) { var value = GetOption(codec, "level"); - if (string.IsNullOrEmpty(value)) - { - return null; - } - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (double.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } @@ -970,12 +959,8 @@ namespace MediaBrowser.Model.Dlna public int? GetTargetRefFrames(string codec) { var value = GetOption(codec, "maxrefframes"); - if (string.IsNullOrEmpty(value)) - { - return null; - } - if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { return result; } diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index 2a86fded22..8fab1ca6d6 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -780,6 +780,12 @@ namespace MediaBrowser.Model.Dto public string TimerId { get; set; } /// <summary> + /// Gets or sets the LUFS value. + /// </summary> + /// <value>The LUFS Value.</value> + public float? LUFS { get; set; } + + /// <summary> /// Gets or sets the current program. /// </summary> /// <value>The current program.</value> diff --git a/MediaBrowser.Model/Dto/BaseItemPerson.cs b/MediaBrowser.Model/Dto/BaseItemPerson.cs index 9c65a2308d..d3bcf492d8 100644 --- a/MediaBrowser.Model/Dto/BaseItemPerson.cs +++ b/MediaBrowser.Model/Dto/BaseItemPerson.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Dto @@ -33,7 +34,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the type. /// </summary> /// <value>The type.</value> - public string Type { get; set; } + public PersonKind Type { get; set; } /// <summary> /// Gets or sets the primary image tag. diff --git a/MediaBrowser.Model/Dto/UserDto.cs b/MediaBrowser.Model/Dto/UserDto.cs index 256d7b10f1..05019741e0 100644 --- a/MediaBrowser.Model/Dto/UserDto.cs +++ b/MediaBrowser.Model/Dto/UserDto.cs @@ -66,6 +66,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets a value indicating whether this instance has configured easy password. /// </summary> /// <value><c>true</c> if this instance has configured easy password; otherwise, <c>false</c>.</value> + [Obsolete("Easy Password has been replaced with Quick Connect")] public bool HasConfiguredEasyPassword { get; set; } /// <summary> diff --git a/MediaBrowser.Model/Entities/IHasShares.cs b/MediaBrowser.Model/Entities/IHasShares.cs new file mode 100644 index 0000000000..b34d1a0376 --- /dev/null +++ b/MediaBrowser.Model/Entities/IHasShares.cs @@ -0,0 +1,12 @@ +namespace MediaBrowser.Model.Entities; + +/// <summary> +/// Interface for access to shares. +/// </summary> +public interface IHasShares +{ + /// <summary> + /// Gets or sets the shares. + /// </summary> + Share[] Shares { get; set; } +} diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 47341f4e17..34642b83aa 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Extensions; @@ -148,7 +149,7 @@ namespace MediaBrowser.Model.Entities /// Gets the video range. /// </summary> /// <value>The video range.</value> - public string VideoRange + public VideoRange VideoRange { get { @@ -162,7 +163,7 @@ namespace MediaBrowser.Model.Entities /// Gets the video range type. /// </summary> /// <value>The video range type.</value> - public string VideoRangeType + public VideoRangeType VideoRangeType { get { @@ -306,9 +307,9 @@ namespace MediaBrowser.Model.Entities attributes.Add(Codec.ToUpperInvariant()); } - if (!string.IsNullOrEmpty(VideoRange)) + if (VideoRange != VideoRange.Unknown) { - attributes.Add(VideoRange.ToUpperInvariant()); + attributes.Add(VideoRange.ToString()); } if (!string.IsNullOrEmpty(Title)) @@ -677,23 +678,23 @@ namespace MediaBrowser.Model.Entities return true; } - public (string VideoRange, string VideoRangeType) GetVideoColorRange() + public (VideoRange VideoRange, VideoRangeType VideoRangeType) GetVideoColorRange() { if (Type != MediaStreamType.Video) { - return (null, null); + return (VideoRange.Unknown, VideoRangeType.Unknown); } var colorTransfer = ColorTransfer; if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)) { - return ("HDR", "HDR10"); + return (VideoRange.HDR, VideoRangeType.HDR10); } if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) { - return ("HDR", "HLG"); + return (VideoRange.HDR, VideoRangeType.HLG); } var codecTag = CodecTag; @@ -711,10 +712,10 @@ namespace MediaBrowser.Model.Entities || string.Equals(codecTag, "dvhe", StringComparison.OrdinalIgnoreCase) || string.Equals(codecTag, "dav1", StringComparison.OrdinalIgnoreCase)) { - return ("HDR", "DOVI"); + return (VideoRange.HDR, VideoRangeType.DOVI); } - return ("SDR", "SDR"); + return (VideoRange.SDR, VideoRangeType.SDR); } } } diff --git a/MediaBrowser.Model/Entities/ParentalRating.cs b/MediaBrowser.Model/Entities/ParentalRating.cs index 17b2868a31..c92640818c 100644 --- a/MediaBrowser.Model/Entities/ParentalRating.cs +++ b/MediaBrowser.Model/Entities/ParentalRating.cs @@ -12,7 +12,7 @@ namespace MediaBrowser.Model.Entities { } - public ParentalRating(string name, int value) + public ParentalRating(string name, int? value) { Name = name; Value = value; @@ -28,6 +28,6 @@ namespace MediaBrowser.Model.Entities /// Gets or sets the value. /// </summary> /// <value>The value.</value> - public int Value { get; set; } + public int? Value { get; set; } } } diff --git a/MediaBrowser.Model/Entities/Share.cs b/MediaBrowser.Model/Entities/Share.cs new file mode 100644 index 0000000000..186aad1892 --- /dev/null +++ b/MediaBrowser.Model/Entities/Share.cs @@ -0,0 +1,17 @@ +namespace MediaBrowser.Model.Entities; + +/// <summary> +/// Class to hold data on sharing permissions. +/// </summary> +public class Share +{ + /// <summary> + /// Gets or sets the user id. + /// </summary> + public string? UserId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the user has edit permissions. + /// </summary> + public bool CanEdit { get; set; } +} diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index e00157dce9..02a29e7faf 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -30,8 +30,9 @@ namespace MediaBrowser.Model.Globalization /// Gets the rating level. /// </summary> /// <param name="rating">The rating.</param> + /// <param name="countryCode">The optional two letter ISO language string.</param> /// <returns><see cref="int" /> or <c>null</c>.</returns> - int? GetRatingLevel(string rating); + int? GetRatingLevel(string rating, string? countryCode = null); /// <summary> /// Gets the localized string. diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 521ba0f107..9a58044853 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -33,14 +33,14 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" /> - <PackageReference Include="MimeTypes" Version="2.4.0"> + <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> + <PackageReference Include="MimeTypes"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="System.Globalization" Version="4.3.0" /> - <PackageReference Include="System.Text.Json" Version="7.0.1" /> + <PackageReference Include="System.Globalization" /> + <PackageReference Include="System.Text.Json" /> </ItemGroup> <ItemGroup> @@ -49,13 +49,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> <ProjectReference Include="../Jellyfin.Data/Jellyfin.Data.csproj" /> diff --git a/MediaBrowser.Model/MediaInfo/AudioCodec.cs b/MediaBrowser.Model/MediaInfo/AudioCodec.cs index 7b83b1b9df..4c22af4498 100644 --- a/MediaBrowser.Model/MediaInfo/AudioCodec.cs +++ b/MediaBrowser.Model/MediaInfo/AudioCodec.cs @@ -17,11 +17,13 @@ namespace MediaBrowser.Model.MediaInfo { return "Dolby Digital"; } - else if (string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) { return "Dolby Digital+"; } - else if (string.Equals(codec, "dca", StringComparison.OrdinalIgnoreCase)) + + if (string.Equals(codec, "dca", StringComparison.OrdinalIgnoreCase)) { return "DTS"; } diff --git a/MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs b/MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs new file mode 100644 index 0000000000..d546ffccdc --- /dev/null +++ b/MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs @@ -0,0 +1,41 @@ +#nullable disable + +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Model.MediaInfo; + +/// <summary> +/// Represents the result of BDInfo output. +/// </summary> +public class BlurayDiscInfo +{ + /// <summary> + /// Gets or sets the media streams. + /// </summary> + /// <value>The media streams.</value> + public MediaStream[] MediaStreams { get; set; } + + /// <summary> + /// Gets or sets the run time ticks. + /// </summary> + /// <value>The run time ticks.</value> + public long? RunTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the files. + /// </summary> + /// <value>The files.</value> + public string[] Files { get; set; } + + /// <summary> + /// Gets or sets the playlist name. + /// </summary> + /// <value>The playlist name.</value> + public string PlaylistName { get; set; } + + /// <summary> + /// Gets or sets the chapters. + /// </summary> + /// <value>The chapters.</value> + public double[] Chapters { get; set; } +} diff --git a/MediaBrowser.Model/MediaInfo/IBlurayExaminer.cs b/MediaBrowser.Model/MediaInfo/IBlurayExaminer.cs new file mode 100644 index 0000000000..d397253010 --- /dev/null +++ b/MediaBrowser.Model/MediaInfo/IBlurayExaminer.cs @@ -0,0 +1,14 @@ +namespace MediaBrowser.Model.MediaInfo; + +/// <summary> +/// Interface IBlurayExaminer. +/// </summary> +public interface IBlurayExaminer +{ + /// <summary> + /// Gets the disc info. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>BlurayDiscInfo.</returns> + BlurayDiscInfo GetDiscInfo(string path); +} diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index 8157dc0c24..5a1871070d 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -117,7 +117,9 @@ namespace MediaBrowser.Model.Net // Type image { "image/jpeg", ".jpg" }, + { "image/tiff", ".tiff" }, { "image/x-png", ".png" }, + { "image/x-icon", ".ico" }, // Type text { "text/plain", ".txt" }, @@ -178,5 +180,8 @@ namespace MediaBrowser.Model.Net var extension = Model.MimeTypes.GetMimeTypeExtensions(mimeType).FirstOrDefault(); return string.IsNullOrEmpty(extension) ? null : "." + extension; } + + public static bool IsImage(ReadOnlySpan<char> mimeType) + => mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); } } diff --git a/MediaBrowser.Model/Net/WebSocketMessage.cs b/MediaBrowser.Model/Net/WebSocketMessage.cs deleted file mode 100644 index b00158cb33..0000000000 --- a/MediaBrowser.Model/Net/WebSocketMessage.cs +++ /dev/null @@ -1,31 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Model.Session; - -namespace MediaBrowser.Model.Net -{ - /// <summary> - /// Class WebSocketMessage. - /// </summary> - /// <typeparam name="T">The type of the data.</typeparam> - public class WebSocketMessage<T> - { - /// <summary> - /// Gets or sets the type of the message. - /// </summary> - /// <value>The type of the message.</value> - public SessionMessageType MessageType { get; set; } - - public Guid MessageId { get; set; } - - public string ServerId { get; set; } - - /// <summary> - /// Gets or sets the data. - /// </summary> - /// <value>The data.</value> - public T Data { get; set; } - } -} diff --git a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs index e8ee494034..8472697164 100644 --- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs +++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs @@ -1,19 +1,36 @@ -#nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Model.Playlists; -namespace MediaBrowser.Model.Playlists +/// <summary> +/// A playlist creation request. +/// </summary> +public class PlaylistCreationRequest { - public class PlaylistCreationRequest - { - public string Name { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the list of items. + /// </summary> + public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>(); - public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>(); + /// <summary> + /// Gets or sets the media type. + /// </summary> + public string? MediaType { get; set; } - public string MediaType { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid UserId { get; set; } - public Guid UserId { get; set; } - } + /// <summary> + /// Gets or sets the shares. + /// </summary> + public Share[]? Shares { get; set; } } diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs index 6f159d653c..ec67d7ea87 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs @@ -1,42 +1,30 @@ using System; -namespace MediaBrowser.Model.SyncPlay +namespace MediaBrowser.Model.SyncPlay; + +/// <summary> +/// Group update without data. +/// </summary> +public abstract class GroupUpdate { /// <summary> - /// Class GroupUpdate. + /// Initializes a new instance of the <see cref="GroupUpdate"/> class. /// </summary> - /// <typeparam name="T">The type of the data of the message.</typeparam> - public class GroupUpdate<T> + /// <param name="groupId">The group identifier.</param> + protected GroupUpdate(Guid groupId) { - /// <summary> - /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class. - /// </summary> - /// <param name="groupId">The group identifier.</param> - /// <param name="type">The update type.</param> - /// <param name="data">The update data.</param> - public GroupUpdate(Guid groupId, GroupUpdateType type, T data) - { - GroupId = groupId; - Type = type; - Data = data; - } - - /// <summary> - /// Gets the group identifier. - /// </summary> - /// <value>The group identifier.</value> - public Guid GroupId { get; } + GroupId = groupId; + } - /// <summary> - /// Gets the update type. - /// </summary> - /// <value>The update type.</value> - public GroupUpdateType Type { get; } + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public Guid GroupId { get; } - /// <summary> - /// Gets the update data. - /// </summary> - /// <value>The update data.</value> - public T Data { get; } - } + /// <summary> + /// Gets the update type. + /// </summary> + /// <value>The update type.</value> + public GroupUpdateType Type { get; init; } } diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs new file mode 100644 index 0000000000..25cd444611 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs @@ -0,0 +1,31 @@ +#pragma warning disable SA1649 + +using System; + +namespace MediaBrowser.Model.SyncPlay; + +/// <summary> +/// Class GroupUpdate. +/// </summary> +/// <typeparam name="T">The type of the data of the message.</typeparam> +public class GroupUpdate<T> : GroupUpdate +{ + /// <summary> + /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class. + /// </summary> + /// <param name="groupId">The group identifier.</param> + /// <param name="type">The update type.</param> + /// <param name="data">The update data.</param> + public GroupUpdate(Guid groupId, GroupUpdateType type, T data) + : base(groupId) + { + Data = data; + Type = type; + } + + /// <summary> + /// Gets the update data. + /// </summary> + /// <value>The update data.</value> + public T Data { get; } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs index cce99c77d5..376d926c9a 100644 --- a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Model.SyncPlay /// <param name="isPlaying">The playing item status.</param> /// <param name="shuffleMode">The shuffle mode.</param> /// <param name="repeatMode">The repeat mode.</param> - public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, bool isPlaying, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode) + public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<SyncPlayQueueItem> playlist, int playingItemIndex, long startPositionTicks, bool isPlaying, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode) { Reason = reason; LastUpdate = lastUpdate; @@ -47,7 +47,7 @@ namespace MediaBrowser.Model.SyncPlay /// Gets the playlist. /// </summary> /// <value>The playlist.</value> - public IReadOnlyList<QueueItem> Playlist { get; } + public IReadOnlyList<SyncPlayQueueItem> Playlist { get; } /// <summary> /// Gets the playing item index in the playlist. diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/SyncPlayQueueItem.cs index a6dcc109ed..da81fecbdc 100644 --- a/MediaBrowser.Model/SyncPlay/QueueItem.cs +++ b/MediaBrowser.Model/SyncPlay/SyncPlayQueueItem.cs @@ -5,13 +5,13 @@ namespace MediaBrowser.Model.SyncPlay /// <summary> /// Class QueueItem. /// </summary> - public class QueueItem + public class SyncPlayQueueItem { /// <summary> - /// Initializes a new instance of the <see cref="QueueItem"/> class. + /// Initializes a new instance of the <see cref="SyncPlayQueueItem"/> class. /// </summary> /// <param name="itemId">The item identifier.</param> - public QueueItem(Guid itemId) + public SyncPlayQueueItem(Guid itemId) { ItemId = itemId; } diff --git a/MediaBrowser.Model/Tasks/ITaskManager.cs b/MediaBrowser.Model/Tasks/ITaskManager.cs index 13bebc479e..5b55667e82 100644 --- a/MediaBrowser.Model/Tasks/ITaskManager.cs +++ b/MediaBrowser.Model/Tasks/ITaskManager.cs @@ -9,9 +9,9 @@ namespace MediaBrowser.Model.Tasks { public interface ITaskManager : IDisposable { - event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting; - event EventHandler<TaskCompletionEventArgs> TaskCompleted; + event EventHandler<TaskCompletionEventArgs>? TaskCompleted; /// <summary> /// Gets the list of Scheduled Tasks. diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 3634d07058..8354c60efb 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591, CA1819 using System; +using System.ComponentModel; using System.Xml.Serialization; using Jellyfin.Data.Enums; using AccessSchedule = Jellyfin.Data.Entities.AccessSchedule; @@ -13,6 +14,7 @@ namespace MediaBrowser.Model.Users public UserPolicy() { IsHidden = true; + EnableCollectionManagement = false; EnableContentDeletion = false; EnableContentDeletionFromFolders = Array.Empty<string>(); @@ -35,6 +37,7 @@ namespace MediaBrowser.Model.Users EnableSharedDeviceControl = true; BlockedTags = Array.Empty<string>(); + AllowedTags = Array.Empty<string>(); BlockUnratedItems = Array.Empty<UnratedItem>(); EnableUserPreferenceAccess = true; @@ -44,6 +47,7 @@ namespace MediaBrowser.Model.Users LoginAttemptsBeforeLockout = -1; MaxActiveSessions = 0; + MaxParentalRating = null; EnableAllChannels = true; EnabledChannels = Array.Empty<Guid>(); @@ -73,6 +77,13 @@ namespace MediaBrowser.Model.Users public bool IsHidden { get; set; } /// <summary> + /// Gets or sets a value indicating whether this instance can manage collections. + /// </summary> + /// <value><c>true</c> if this instance is hidden; otherwise, <c>false</c>.</value> + [DefaultValue(false)] + public bool EnableCollectionManagement { get; set; } + + /// <summary> /// Gets or sets a value indicating whether this instance is disabled. /// </summary> /// <value><c>true</c> if this instance is disabled; otherwise, <c>false</c>.</value> @@ -86,6 +97,8 @@ namespace MediaBrowser.Model.Users public string[] BlockedTags { get; set; } + public string[] AllowedTags { get; set; } + public bool EnableUserPreferenceAccess { get; set; } public AccessSchedule[] AccessSchedules { get; set; } diff --git a/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs b/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs index 96a9e9dcf3..a9099d1927 100644 --- a/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs +++ b/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.IO; -using System.Linq; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Lyrics; diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index d621555f13..dab36625e5 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -32,6 +32,7 @@ namespace MediaBrowser.Providers.Manager private readonly ILogger _logger; private readonly IProviderManager _providerManager; private readonly IFileSystem _fileSystem; + private static readonly ImageType[] AllImageTypes = Enum.GetValues<ImageType>(); /// <summary> /// Image types that are only one per item. @@ -90,11 +91,12 @@ namespace MediaBrowser.Providers.Manager /// </summary> /// <param name="item">The <see cref="BaseItem"/> to validate images for.</param> /// <param name="providers">The providers to use, must include <see cref="ILocalImageProvider"/>(s) for local scanning.</param> - /// <param name="directoryService">The directory service for <see cref="ILocalImageProvider"/>s to use.</param> + /// <param name="refreshOptions">The refresh options.</param> /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns> - public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, IDirectoryService directoryService) + public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions) { var hasChanges = false; + IDirectoryService directoryService = refreshOptions?.DirectoryService; if (item is not Photo) { @@ -102,7 +104,7 @@ namespace MediaBrowser.Providers.Manager .SelectMany(i => i.GetImages(item, directoryService)) .ToList(); - if (MergeImages(item, images)) + if (MergeImages(item, images, refreshOptions)) { hasChanges = true; } @@ -273,7 +275,7 @@ namespace MediaBrowser.Providers.Manager } if (!refreshOptions.ReplaceAllImages && - refreshOptions.ReplaceImages.Length == 0 && + refreshOptions.ReplaceImages.Count == 0 && ContainsImages(item, provider.GetSupportedImages(item).ToList(), savedOptions, backdropLimit)) { return; @@ -313,7 +315,8 @@ namespace MediaBrowser.Providers.Manager } minWidth = savedOptions.GetMinWidth(ImageType.Backdrop); - await DownloadMultiImages(item, ImageType.Backdrop, refreshOptions, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); + var listWithNoLangFirst = list.OrderByDescending(i => string.IsNullOrEmpty(i.Language)); + await DownloadMultiImages(item, ImageType.Backdrop, refreshOptions, backdropLimit, provider, result, listWithNoLangFirst, minWidth, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -383,12 +386,33 @@ namespace MediaBrowser.Providers.Manager /// <summary> /// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary. /// </summary> + /// <param name="refreshOptions">The refresh options.</param> + /// <param name="dontReplaceImages">List of imageTypes to remove from ReplaceImages.</param> + public void UpdateReplaceImages(ImageRefreshOptions refreshOptions, ICollection<ImageType> dontReplaceImages) + { + if (refreshOptions is not null) + { + if (refreshOptions.ReplaceAllImages) + { + refreshOptions.ReplaceAllImages = false; + refreshOptions.ReplaceImages = AllImageTypes.ToList(); + } + + refreshOptions.ReplaceImages = refreshOptions.ReplaceImages.Except(dontReplaceImages).ToList(); + } + } + + /// <summary> + /// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary. + /// </summary> /// <param name="item">The <see cref="BaseItem"/> to modify.</param> /// <param name="images">The new images to place in <c>item</c>.</param> + /// <param name="refreshOptions">The refresh options.</param> /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns> - public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images) + public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images, ImageRefreshOptions refreshOptions) { var changed = item.ValidateImages(); + var foundImageTypes = new List<ImageType>(); for (var i = 0; i < _singularImages.Length; i++) { @@ -398,6 +422,11 @@ namespace MediaBrowser.Providers.Manager if (image is not null) { var currentImage = item.GetImageInfo(type, 0); + // if image file is stored with media, don't replace that later + if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase)) + { + foundImageTypes.Add(type); + } if (currentImage is null || !string.Equals(currentImage.Path, image.FileInfo.FullName, StringComparison.OrdinalIgnoreCase)) { @@ -424,6 +453,12 @@ namespace MediaBrowser.Providers.Manager if (UpdateMultiImages(item, images, ImageType.Backdrop)) { changed = true; + foundImageTypes.Add(ImageType.Backdrop); + } + + if (foundImageTypes.Count > 0) + { + UpdateReplaceImages(refreshOptions, foundImageTypes); } return changed; diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index ffae772008..834ef29f51 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -12,6 +12,7 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; @@ -108,7 +109,7 @@ namespace MediaBrowser.Providers.Manager try { // Always validate images and check for new locally stored ones. - if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions.DirectoryService)) + if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions)) { updateType |= ItemUpdateType.ImageUpdate; } @@ -151,7 +152,6 @@ namespace MediaBrowser.Providers.Manager ApplySearchResult(id, refreshOptions.SearchResult); } - // await FindIdentities(id, cancellationToken).ConfigureAwait(false); id.IsAutomated = refreshOptions.IsAutomated; var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, cancellationToken).ConfigureAwait(false); @@ -334,6 +334,12 @@ namespace MediaBrowser.Providers.Manager updateType |= UpdateCumulativeRunTimeTicks(item, children); updateType |= UpdateDateLastMediaAdded(item, children); + // don't update user-changeable metadata for locked items + if (item.IsLocked) + { + return updateType; + } + if (EnableUpdatingPremiereDateFromChildren) { updateType |= UpdatePremiereDate(item, children); @@ -375,7 +381,7 @@ namespace MediaBrowser.Providers.Manager if (!folder.RunTimeTicks.HasValue || folder.RunTimeTicks.Value != ticks) { folder.RunTimeTicks = ticks; - return ItemUpdateType.MetadataEdit; + return ItemUpdateType.MetadataImport; } } @@ -667,6 +673,7 @@ namespace MediaBrowser.Providers.Manager } var hasLocalMetadata = false; + var foundImageTypes = new List<ImageType>(); foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>()) { @@ -693,6 +700,9 @@ namespace MediaBrowser.Providers.Manager await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false); refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + + // remember imagetype that has just been downloaded + foundImageTypes.Add(remoteImage.Type); } catch (HttpRequestException ex) { @@ -700,7 +710,12 @@ namespace MediaBrowser.Providers.Manager } } - if (imageService.MergeImages(item, localItem.Images)) + if (foundImageTypes.Count > 0) + { + imageService.UpdateReplaceImages(options, foundImageTypes); + } + + if (imageService.MergeImages(item, localItem.Images, options)) { refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; } @@ -863,10 +878,7 @@ namespace MediaBrowser.Providers.Manager var key = providerId.Key; // Don't replace existing Id's. - if (!lookupInfo.ProviderIds.ContainsKey(key)) - { - lookupInfo.ProviderIds[key] = providerId.Value; - } + lookupInfo.ProviderIds.TryAdd(key, providerId.Value); } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 0ce696edc6..5cb28402e8 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -131,12 +131,12 @@ namespace MediaBrowser.Providers.Manager { var type = item.GetType(); - var service = _metadataServices.FirstOrDefault(current => current.CanRefreshPrimary(type)); - service ??= _metadataServices.FirstOrDefault(current => current.CanRefresh(item)); + var service = _metadataServices.FirstOrDefault(current => current.CanRefreshPrimary(type)) + ?? _metadataServices.FirstOrDefault(current => current.CanRefresh(item)); if (service is null) { - _logger.LogError("Unable to find a metadata service for item of type {TypeName}", item.GetType().Name); + _logger.LogError("Unable to find a metadata service for item of type {TypeName}", type.Name); return Task.FromResult(ItemUpdateType.None); } @@ -160,7 +160,7 @@ namespace MediaBrowser.Providers.Manager // TODO: Isolate this hack into the tvh plugin if (string.IsNullOrEmpty(contentType)) { - if (url.IndexOf("/imagecache/", StringComparison.OrdinalIgnoreCase) != -1) + if (url.Contains("/imagecache/", StringComparison.OrdinalIgnoreCase)) { contentType = "image/png"; } @@ -232,6 +232,11 @@ namespace MediaBrowser.Providers.Manager providers = providers.Where(i => string.Equals(i.Name, providerName, StringComparison.OrdinalIgnoreCase)); } + if (query.ImageType is not null) + { + providers = providers.Where(i => i.GetSupportedImages(item).Contains(query.ImageType.Value)); + } + var preferredLanguage = item.GetPreferredMetadataLanguage(); var tasks = providers.Select(i => GetImages(item, i, preferredLanguage, query.IncludeAllLanguages, cancellationToken, query.ImageType)); @@ -284,12 +289,12 @@ namespace MediaBrowser.Providers.Manager } catch (OperationCanceledException) { - return new List<RemoteImageInfo>(); + return Enumerable.Empty<RemoteImageInfo>(); } catch (Exception ex) { _logger.LogError(ex, "{ProviderName} failed in GetImageInfos for type {ItemType} at {ItemPath}", provider.GetType().Name, item.GetType().Name, item.Path); - return new List<RemoteImageInfo>(); + return Enumerable.Empty<RemoteImageInfo>(); } } @@ -404,12 +409,6 @@ namespace MediaBrowser.Providers.Manager return false; } - // Prevent owned items from reading the same local metadata file as their owner - if (!item.OwnerId.Equals(default) && provider is ILocalMetadataProvider) - { - return false; - } - if (includeDisabled) { return true; @@ -574,13 +573,7 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public MetadataOptions GetMetadataOptions(BaseItem item) - { - var type = item.GetType().Name; - - return _configurationManager.Configuration.MetadataOptions - .FirstOrDefault(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) ?? - new MetadataOptions(); - } + => _configurationManager.GetMetadataOptionsForType(item.GetType().Name) ?? new MetadataOptions(); /// <inheritdoc/> public Task SaveMetadataAsync(BaseItem item, ItemUpdateType updateType) @@ -786,10 +779,7 @@ namespace MediaBrowser.Providers.Manager { foreach (var providerId in result.ProviderIds) { - if (!existingMatch.ProviderIds.ContainsKey(providerId.Key)) - { - existingMatch.ProviderIds.Add(providerId.Key, providerId.Value); - } + existingMatch.ProviderIds.TryAdd(providerId.Key, providerId.Value); } if (string.IsNullOrWhiteSpace(existingMatch.ImageUrl)) @@ -818,27 +808,12 @@ namespace MediaBrowser.Providers.Manager { var results = await provider.GetSearchResults(searchInfo, cancellationToken).ConfigureAwait(false); - var list = results.ToList(); - - foreach (var item in list) + foreach (var item in results) { item.SearchProviderName = provider.Name; } - return list; - } - - /// <inheritdoc/> - public Task<HttpResponseMessage> GetSearchImage(string providerName, string url, CancellationToken cancellationToken) - { - var provider = _metadataProviders.OfType<IRemoteSearchProvider>().FirstOrDefault(i => string.Equals(i.Name, providerName, StringComparison.OrdinalIgnoreCase)); - - if (provider is null) - { - throw new ArgumentException("Search provider not found."); - } - - return provider.GetImageResponse(url, cancellationToken); + return results; } private IEnumerable<IExternalId> GetExternalIds(IHasProviderIds item) @@ -910,19 +885,34 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void OnRefreshStart(BaseItem item) { - _logger.LogDebug("OnRefreshStart {Item}", item.Id.ToString("N", CultureInfo.InvariantCulture)); + _logger.LogDebug("OnRefreshStart {Item:N}", item.Id); _activeRefreshes[item.Id] = 0; - RefreshStarted?.Invoke(this, new GenericEventArgs<BaseItem>(item)); + try + { + RefreshStarted?.Invoke(this, new GenericEventArgs<BaseItem>(item)); + } + catch (Exception ex) + { + // EventHandlers should never propagate exceptions, but we have little control over plugins... + _logger.LogError(ex, "Invoking {RefreshEvent} event handlers failed", nameof(RefreshStarted)); + } } /// <inheritdoc/> public void OnRefreshComplete(BaseItem item) { - _logger.LogDebug("OnRefreshComplete {Item}", item.Id.ToString("N", CultureInfo.InvariantCulture)); + _logger.LogDebug("OnRefreshComplete {Item:N}", item.Id); + _activeRefreshes.TryRemove(item.Id, out _); - _activeRefreshes.Remove(item.Id, out _); - - RefreshCompleted?.Invoke(this, new GenericEventArgs<BaseItem>(item)); + try + { + RefreshCompleted?.Invoke(this, new GenericEventArgs<BaseItem>(item)); + } + catch (Exception ex) + { + // EventHandlers should never propagate exceptions, but we have little control over plugins... + _logger.LogError(ex, "Invoking {RefreshEvent} event handlers failed", nameof(RefreshCompleted)); + } } /// <inheritdoc/> @@ -940,12 +930,12 @@ namespace MediaBrowser.Providers.Manager public void OnRefreshProgress(BaseItem item, double progress) { var id = item.Id; - _logger.LogDebug("OnRefreshProgress {Id} {Progress}", id.ToString("N", CultureInfo.InvariantCulture), progress); + _logger.LogDebug("OnRefreshProgress {Id:N} {Progress}", id, progress); // TODO: Need to hunt down the conditions for this happening _activeRefreshes.AddOrUpdate( id, - (_) => throw new InvalidOperationException( + _ => throw new InvalidOperationException( string.Format( CultureInfo.InvariantCulture, "Cannot update refresh progress of item '{0}' ({1}) because a refresh for this item is not running", @@ -953,7 +943,15 @@ namespace MediaBrowser.Providers.Manager item.Id.ToString("N", CultureInfo.InvariantCulture))), (_, _) => progress); - RefreshProgress?.Invoke(this, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(item, progress))); + try + { + RefreshProgress?.Invoke(this, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(item, progress))); + } + catch (Exception ex) + { + // EventHandlers should never propagate exceptions, but we have little control over plugins... + _logger.LogError(ex, "Invoking {RefreshEvent} event handlers failed", nameof(RefreshProgress)); + } } /// <inheritdoc/> @@ -1088,29 +1086,6 @@ namespace MediaBrowser.Providers.Manager return RefreshItem(item, options, cancellationToken); } - /// <summary> - /// Runs multiple metadata refreshes concurrently. - /// </summary> - /// <param name="action">The action to run.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns> - public async Task RunMetadataRefresh(Func<Task> action, CancellationToken cancellationToken) - { - // create a variable for this since it is possible MetadataRefreshThrottler could change due to a config update during a scan - var metadataRefreshThrottler = _baseItemManager.MetadataRefreshThrottler; - - await metadataRefreshThrottler.WaitAsync(cancellationToken).ConfigureAwait(false); - - try - { - await action().ConfigureAwait(false); - } - finally - { - metadataRefreshThrottler.Release(); - } - } - /// <inheritdoc/> public void Dispose() { diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 9e238e9f3a..6a40833d7f 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -15,15 +15,15 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="LrcParser" Version="2022.529.1" /> - <PackageReference Include="MetaBrainz.MusicBrainz" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" /> - <PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> - <PackageReference Include="PlaylistsNET" Version="1.3.1" /> - <PackageReference Include="TagLibSharp" Version="2.3.0" /> - <PackageReference Include="TMDbLib" Version="2.0.0" /> + <PackageReference Include="LrcParser" /> + <PackageReference Include="MetaBrainz.MusicBrainz" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> + <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" /> + <PackageReference Include="Microsoft.Extensions.Http" /> + <PackageReference Include="Newtonsoft.Json" /> + <PackageReference Include="PlaylistsNET" /> + <PackageReference Include="TagLibSharp" /> + <PackageReference Include="TMDbLib" /> </ItemGroup> <PropertyGroup> @@ -35,13 +35,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 74210b1f22..e1dcbc9939 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -1,8 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -13,6 +17,7 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; using TagLib; namespace MediaBrowser.Providers.MediaInfo @@ -22,6 +27,10 @@ namespace MediaBrowser.Providers.MediaInfo /// </summary> public class AudioFileProber { + // Default LUFS value for use with the web interface, at -18db gain will be 1(no db gain). + private const float DefaultLUFSValue = -18; + + private readonly ILogger<AudioFileProber> _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IItemRepository _itemRepo; private readonly ILibraryManager _libraryManager; @@ -30,16 +39,19 @@ namespace MediaBrowser.Providers.MediaInfo /// <summary> /// Initializes a new instance of the <see cref="AudioFileProber"/> class. /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> public AudioFileProber( + ILogger<AudioFileProber> logger, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IItemRepository itemRepo, ILibraryManager libraryManager) { + _logger = logger; _mediaEncoder = mediaEncoder; _itemRepo = itemRepo; _libraryManager = libraryManager; @@ -88,6 +100,54 @@ namespace MediaBrowser.Providers.MediaInfo Fetch(item, result, cancellationToken); } + var libraryOptions = _libraryManager.GetLibraryOptions(item); + + if (libraryOptions.EnableLUFSScan) + { + string output; + using (var process = new Process() + { + StartInfo = new ProcessStartInfo + { + FileName = _mediaEncoder.EncoderPath, + Arguments = $"-hide_banner -i \"{path}\" -af ebur128=framelog=verbose -f null -", + RedirectStandardOutput = false, + RedirectStandardError = true + }, + }) + { + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting ffmpeg"); + + throw; + } + + output = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + MatchCollection split = Regex.Matches(output, @"I:\s+(.*?)\s+LUFS"); + + if (split.Count != 0) + { + item.LUFS = float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + } + else + { + item.LUFS = DefaultLUFSValue; + } + } + } + else + { + item.LUFS = DefaultLUFSValue; + } + + _logger.LogDebug("LUFS for {ItemName} is {LUFS}.", item.Name, item.LUFS); + return ItemUpdateType.MetadataImport; } @@ -105,7 +165,10 @@ namespace MediaBrowser.Providers.MediaInfo audio.RunTimeTicks = mediaInfo.RunTimeTicks; audio.Size = mediaInfo.Size; - FetchDataFromTags(audio); + if (!audio.IsLocked) + { + FetchDataFromTags(audio); + } _itemRepo.SaveMediaStreams(audio.Id, mediaInfo.MediaStreams, cancellationToken); } @@ -160,7 +223,7 @@ namespace MediaBrowser.Providers.MediaInfo PeopleHelper.AddPerson(people, new PersonInfo { Name = albumArtist, - Type = "AlbumArtist" + Type = PersonKind.AlbumArtist }); } @@ -170,7 +233,7 @@ namespace MediaBrowser.Providers.MediaInfo PeopleHelper.AddPerson(people, new PersonInfo { Name = performer, - Type = "Artist" + Type = PersonKind.Artist }); } @@ -179,7 +242,7 @@ namespace MediaBrowser.Providers.MediaInfo PeopleHelper.AddPerson(people, new PersonInfo { Name = composer, - Type = "Composer" + Type = PersonKind.Composer }); } @@ -192,6 +255,7 @@ namespace MediaBrowser.Providers.MediaInfo audio.Album = tags.Album; audio.IndexNumber = Convert.ToInt32(tags.Track); audio.ParentIndexNumber = Convert.ToInt32(tags.Disc); + if (tags.Year != 0) { var year = Convert.ToInt32(tags.Year); diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 0f35c6a5ea..213639371a 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -36,6 +36,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly ILogger<FFProbeVideoInfo> _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IItemRepository _itemRepo; + private readonly IBlurayExaminer _blurayExaminer; private readonly ILocalizationManager _localization; private readonly IEncodingManager _encodingManager; private readonly IServerConfigurationManager _config; @@ -51,6 +52,7 @@ namespace MediaBrowser.Providers.MediaInfo IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IItemRepository itemRepo, + IBlurayExaminer blurayExaminer, ILocalizationManager localization, IEncodingManager encodingManager, IServerConfigurationManager config, @@ -64,6 +66,7 @@ namespace MediaBrowser.Providers.MediaInfo _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; _itemRepo = itemRepo; + _blurayExaminer = blurayExaminer; _localization = localization; _encodingManager = encodingManager; _config = config; @@ -80,16 +83,77 @@ namespace MediaBrowser.Providers.MediaInfo CancellationToken cancellationToken) where T : Video { + BlurayDiscInfo blurayDiscInfo = null; + Model.MediaInfo.MediaInfo mediaInfoResult = null; if (!item.IsShortcut || options.EnableRemoteContentProbe) { - mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false); + if (item.VideoType == VideoType.Dvd) + { + // Get list of playable .vob files + var vobs = _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, null); + + // Return if no playable .vob files are found + if (vobs.Count == 0) + { + _logger.LogError("No playable .vob files found in DVD structure, skipping FFprobe."); + return ItemUpdateType.MetadataImport; + } + + // Fetch metadata of first .vob file + mediaInfoResult = await GetMediaInfo( + new Video + { + Path = vobs[0] + }, + cancellationToken).ConfigureAwait(false); + + // Sum up the runtime of all .vob files skipping the first .vob + for (var i = 1; i < vobs.Count; i++) + { + var tmpMediaInfo = await GetMediaInfo( + new Video + { + Path = vobs[i] + }, + cancellationToken).ConfigureAwait(false); + + mediaInfoResult.RunTimeTicks += tmpMediaInfo.RunTimeTicks; + } + } + else if (item.VideoType == VideoType.BluRay) + { + // Get BD disc information + blurayDiscInfo = GetBDInfo(item.Path); + + // Get playable .m2ts files + var m2ts = _mediaEncoder.GetPrimaryPlaylistM2tsFiles(item.Path); + + // Return if no playable .m2ts files are found + if (blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0) + { + _logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe."); + return ItemUpdateType.MetadataImport; + } + + // Fetch metadata of first .m2ts file + mediaInfoResult = await GetMediaInfo( + new Video + { + Path = m2ts[0] + }, + cancellationToken).ConfigureAwait(false); + } + else + { + mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false); + } cancellationToken.ThrowIfCancellationRequested(); } - await Fetch(item, cancellationToken, mediaInfoResult, options).ConfigureAwait(false); + await Fetch(item, cancellationToken, mediaInfoResult, blurayDiscInfo, options).ConfigureAwait(false); return ItemUpdateType.MetadataImport; } @@ -129,6 +193,7 @@ namespace MediaBrowser.Providers.MediaInfo Video video, CancellationToken cancellationToken, Model.MediaInfo.MediaInfo mediaInfo, + BlurayDiscInfo blurayInfo, MetadataRefreshOptions options) { List<MediaStream> mediaStreams; @@ -153,19 +218,8 @@ namespace MediaBrowser.Providers.MediaInfo } mediaAttachments = mediaInfo.MediaAttachments; - video.TotalBitrate = mediaInfo.Bitrate; - // video.FormatName = (mediaInfo.Container ?? string.Empty) - // .Replace("matroska", "mkv", StringComparison.OrdinalIgnoreCase); - - // For DVDs this may not always be accurate, so don't set the runtime if the item already has one - var needToSetRuntime = video.VideoType != VideoType.Dvd || video.RunTimeTicks is null || video.RunTimeTicks.Value == 0; - - if (needToSetRuntime) - { - video.RunTimeTicks = mediaInfo.RunTimeTicks; - } - + video.RunTimeTicks = mediaInfo.RunTimeTicks; video.Size = mediaInfo.Size; if (video.VideoType == VideoType.VideoFile) @@ -182,6 +236,10 @@ namespace MediaBrowser.Providers.MediaInfo video.Container = mediaInfo.Container; chapters = mediaInfo.Chapters ?? Array.Empty<ChapterInfo>(); + if (blurayInfo is not null) + { + FetchBdInfo(video, ref chapters, mediaStreams, blurayInfo); + } } else { @@ -240,7 +298,7 @@ namespace MediaBrowser.Providers.MediaInfo if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || options.MetadataRefreshMode == MetadataRefreshMode.Default) { - if (chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video)) + if (_config.Configuration.DummyChapterDuration > 0 && chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video)) { chapters = CreateDummyChapters(video); } @@ -277,6 +335,86 @@ namespace MediaBrowser.Providers.MediaInfo } } + private void FetchBdInfo(Video video, ref ChapterInfo[] chapters, List<MediaStream> mediaStreams, BlurayDiscInfo blurayInfo) + { + if (blurayInfo.Files.Length <= 1) + { + return; + } + + // Use BD Info if it has multiple m2ts. Otherwise, treat it like a video file and rely more on ffprobe output + int? currentHeight = null; + int? currentWidth = null; + int? currentBitRate = null; + + var videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video); + + // Grab the values that ffprobe recorded + if (videoStream is not null) + { + currentBitRate = videoStream.BitRate; + currentWidth = videoStream.Width; + currentHeight = videoStream.Height; + } + + // Fill video properties from the BDInfo result + mediaStreams.Clear(); + mediaStreams.AddRange(blurayInfo.MediaStreams); + + if (blurayInfo.RunTimeTicks.HasValue && blurayInfo.RunTimeTicks.Value > 0) + { + video.RunTimeTicks = blurayInfo.RunTimeTicks; + } + + if (blurayInfo.Chapters is not null) + { + double[] brChapter = blurayInfo.Chapters; + chapters = new ChapterInfo[brChapter.Length]; + for (int i = 0; i < brChapter.Length; i++) + { + chapters[i] = new ChapterInfo + { + StartPositionTicks = TimeSpan.FromSeconds(brChapter[i]).Ticks + }; + } + } + + videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video); + + // Use the ffprobe values if these are empty + if (videoStream is not null) + { + videoStream.BitRate = IsEmpty(videoStream.BitRate) ? currentBitRate : videoStream.BitRate; + videoStream.Width = IsEmpty(videoStream.Width) ? currentWidth : videoStream.Width; + videoStream.Height = IsEmpty(videoStream.Height) ? currentHeight : videoStream.Height; + } + } + + private bool IsEmpty(int? num) + { + return !num.HasValue || num.Value == 0; + } + + /// <summary> + /// Gets information about the longest playlist on a bdrom. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>VideoStream.</returns> + private BlurayDiscInfo GetBDInfo(string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + try + { + return _blurayExaminer.GetDiscInfo(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting BDInfo"); + return null; + } + } + private void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions) { var replaceData = refreshOptions.ReplaceAllMetadata; @@ -524,39 +662,39 @@ namespace MediaBrowser.Providers.MediaInfo private ChapterInfo[] CreateDummyChapters(Video video) { var runtime = video.RunTimeTicks ?? 0; - long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks; - if (runtime < 0) + // Only process files with a runtime higher than 0 and lower than 12h. The latter are likely corrupted. + if (runtime < 0 || runtime > TimeSpan.FromHours(12).Ticks) { throw new ArgumentException( string.Format( CultureInfo.InvariantCulture, - "{0} has invalid runtime of {1}", + "{0} has an invalid runtime of {1} minutes", video.Name, - runtime)); + TimeSpan.FromTicks(runtime).Minutes)); } - if (runtime < dummyChapterDuration) + long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks; + if (runtime > dummyChapterDuration) { - return Array.Empty<ChapterInfo>(); - } - - // Limit the chapters just in case there's some incorrect metadata here - int chapterCount = (int)Math.Min(runtime / dummyChapterDuration, _config.Configuration.DummyChapterCount); - var chapters = new ChapterInfo[chapterCount]; + int chapterCount = (int)(runtime / dummyChapterDuration); + var chapters = new ChapterInfo[chapterCount]; - long currentChapterTicks = 0; - for (int i = 0; i < chapterCount; i++) - { - chapters[i] = new ChapterInfo + long currentChapterTicks = 0; + for (int i = 0; i < chapterCount; i++) { - StartPositionTicks = currentChapterTicks - }; + chapters[i] = new ChapterInfo + { + StartPositionTicks = currentChapterTicks + }; + + currentChapterTicks += dummyChapterDuration; + } - currentChapterTicks += dummyChapterDuration; + return chapters; } - return chapters; + return Array.Empty<ChapterInfo>(); } } } diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 31fa3da1ce..114a929753 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -53,6 +53,7 @@ namespace MediaBrowser.Providers.MediaInfo /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param> + /// <param name="blurayExaminer">Instance of the <see cref="IBlurayExaminer"/> interface.</param> /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="encodingManager">Instance of the <see cref="IEncodingManager"/> interface.</param> /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> @@ -66,6 +67,7 @@ namespace MediaBrowser.Providers.MediaInfo IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IItemRepository itemRepo, + IBlurayExaminer blurayExaminer, ILocalizationManager localization, IEncodingManager encodingManager, IServerConfigurationManager config, @@ -77,7 +79,7 @@ namespace MediaBrowser.Providers.MediaInfo NamingOptions namingOptions) { _logger = loggerFactory.CreateLogger<ProbeProvider>(); - _audioProber = new AudioFileProber(mediaSourceManager, mediaEncoder, itemRepo, libraryManager); + _audioProber = new AudioFileProber(loggerFactory.CreateLogger<AudioFileProber>(), mediaSourceManager, mediaEncoder, itemRepo, libraryManager); _audioResolver = new AudioResolver(loggerFactory.CreateLogger<AudioResolver>(), localization, mediaEncoder, fileSystem, namingOptions); _subtitleResolver = new SubtitleResolver(loggerFactory.CreateLogger<SubtitleResolver>(), localization, mediaEncoder, fileSystem, namingOptions); _videoProber = new FFProbeVideoInfo( @@ -85,6 +87,7 @@ namespace MediaBrowser.Providers.MediaInfo mediaSourceManager, mediaEncoder, itemRepo, + blurayExaminer, localization, encodingManager, config, diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs index 58cd23aa34..0ddb2ad67b 100644 --- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs +++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -54,6 +55,12 @@ namespace MediaBrowser.Providers.Music { var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType); + // don't update user-changeable metadata for locked items + if (item.IsLocked) + { + return updateType; + } + if (isFullRefresh || currentUpdateType > ItemUpdateType.None) { if (!item.LockedFields.Contains(MetadataField.Name)) @@ -181,7 +188,7 @@ namespace MediaBrowser.Providers.Music PeopleHelper.AddPerson(people, new PersonInfo { Name = albumArtist, - Type = "AlbumArtist" + Type = PersonKind.AlbumArtist }); } @@ -190,7 +197,7 @@ namespace MediaBrowser.Providers.Music PeopleHelper.AddPerson(people, new PersonInfo { Name = artist, - Type = "Artist" + Type = PersonKind.Artist }); } diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index db4c5f436e..9bd36f25c3 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -87,7 +87,7 @@ namespace MediaBrowser.Providers.Playlists return GetPlsItems(stream); } - return new List<LinkedChild>(); + return Enumerable.Empty<LinkedChild>(); } private IEnumerable<LinkedChild> GetPlsItems(Stream stream) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index b1a285a964..2232dfa0d7 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; @@ -42,7 +43,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> + return new ImageType[] { ImageType.Primary, ImageType.Logo, @@ -74,7 +75,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb } } - return new List<RemoteImageInfo>(); + return Enumerable.Empty<RemoteImageInfo>(); } private IEnumerable<RemoteImageInfo> GetImages(AudioDbArtistProvider.Artist item) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html index eab252005f..2093effca6 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html @@ -1,12 +1,13 @@ <!DOCTYPE html> <html> <head> - <title>AudioDB</title> + <title>TheAudioDB</title> </head> <body> - <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> + <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> <div data-role="content"> <div class="content-primary"> + <h1>TheAudioDB</h1> <form class="configForm"> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="replaceAlbumName" /> diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs index 22229e377d..21a15c58ca 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs @@ -1,5 +1,4 @@ using MediaBrowser.Model.Plugins; -using MetaBrainz.MusicBrainz; namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; @@ -8,16 +7,22 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; /// </summary> public class PluginConfiguration : BasePluginConfiguration { - private const string DefaultServer = "musicbrainz.org"; + /// <summary> + /// The default server URL. + /// </summary> + public const string DefaultServer = "https://musicbrainz.org"; - private const double DefaultRateLimit = 1.0; + /// <summary> + /// The default rate limit. + /// </summary> + public const double DefaultRateLimit = 1.0; private string _server = DefaultServer; private double _rateLimit = DefaultRateLimit; /// <summary> - /// Gets or sets the server url. + /// Gets or sets the server URL. /// </summary> public string Server { diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html index 6f1296bb77..24f2ac0ca9 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html @@ -4,17 +4,18 @@ <title>MusicBrainz</title> </head> <body> - <div data-role="page" class="page type-interior pluginConfigurationPage musicBrainzConfigPage" data-require="emby-input,emby-button,emby-checkbox"> + <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> <div data-role="content"> <div class="content-primary"> - <form class="musicBrainzConfigForm"> + <h1>MusicBrainz</h1> + <form class="configForm"> <div class="inputContainer"> <input is="emby-input" type="text" id="server" required label="Server" /> <div class="fieldDescription">This can be a mirror of the official server or even a custom server.</div> </div> <div class="inputContainer"> - <input is="emby-input" type="number" id="rateLimit" pattern="[0-9]*" required min="0" max="10000" label="Rate Limit" /> - <div class="fieldDescription">Span of time between requests in milliseconds. The official server is limited to one request every two seconds.</div> + <input is="emby-input" type="number" id="rateLimit" required pattern="[0-9]*" min="0" max="10" step=".01" label="Rate Limit" /> + <div class="fieldDescription">Span of time between requests in seconds. The official server is limited to one request every seconds.</div> </div> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="replaceArtistName" /> @@ -32,7 +33,7 @@ uniquePluginId: "8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a" }; - document.querySelector('.musicBrainzConfigPage') + document.querySelector('.configPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) { @@ -49,14 +50,14 @@ bubbles: true, cancelable: false })); - + document.querySelector('#replaceArtistName').checked = config.ReplaceArtistName; Dashboard.hideLoadingMsg(); }); }); - document.querySelector('.musicBrainzConfigForm') + document.querySelector('.configForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 34f45f0d57..d0bd7d6098 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -8,8 +8,10 @@ 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; @@ -23,8 +25,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable { private readonly ILogger<MusicBrainzAlbumProvider> _logger; - private readonly Query _musicBrainzQuery; - private readonly string _musicBrainzDefaultUri = "https://musicbrainz.org"; + private Query _musicBrainzQuery; /// <summary> /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class. @@ -33,29 +34,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu public MusicBrainzAlbumProvider(ILogger<MusicBrainzAlbumProvider> logger) { _logger = logger; - - MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) => - { - if (Uri.TryCreate(MusicBrainz.Plugin.Instance.Configuration.Server, UriKind.Absolute, out var server)) - { - Query.DefaultServer = server.Host; - 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(_musicBrainzDefaultUri); - Query.DefaultServer = defaultServer.Host; - Query.DefaultPort = defaultServer.Port; - Query.DefaultUrlScheme = defaultServer.Scheme; - } - - Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit; - }; - _musicBrainzQuery = new Query(); + ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration); + MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig; } /// <inheritdoc /> @@ -64,6 +45,29 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu /// <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) { @@ -72,13 +76,13 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (!string.IsNullOrEmpty(releaseId)) { - var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); + var releaseResult = await _musicBrainzQuery.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.None, null, cancellationToken).ConfigureAwait(false); + var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false); return GetReleaseGroupResult(releaseGroupResult.Releases); } @@ -133,7 +137,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu foreach (var result in releaseSearchResults) { - yield return GetReleaseResult(result); + // Fetch full release info, otherwise artists are missing + var fullResult = _musicBrainzQuery.LookupRelease(result.Id, Include.Artists | Include.ReleaseGroups); + yield return GetReleaseResult(fullResult); } } @@ -143,21 +149,33 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu { Name = releaseSearchResult.Title, ProductionYear = releaseSearchResult.Date?.Year, - PremiereDate = releaseSearchResult.Date?.NearestDate + PremiereDate = releaseSearchResult.Date?.NearestDate, + SearchProviderName = Name }; - if (releaseSearchResult.ArtistCredit?.Count > 0) + // Add artists and use first as album artist + var artists = releaseSearchResult.ArtistCredit; + if (artists is not null && artists.Count > 0) { - searchResult.AlbumArtist = new RemoteSearchResult + var artistResults = new RemoteSearchResult[artists.Count]; + for (int i = 0; i < artists.Count; i++) { - SearchProviderName = Name, - Name = releaseSearchResult.ArtistCredit[0].Name - }; + var artist = artists[i]; + var artistResult = new RemoteSearchResult + { + Name = artist.Name + }; - if (releaseSearchResult.ArtistCredit[0].Artist?.Id is not null) - { - searchResult.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, releaseSearchResult.ArtistCredit[0].Artist!.Id.ToString()); + if (artist.Artist?.Id is not null) + { + artistResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Artist!.Id.ToString()); + } + + artistResults[i] = artistResult; } + + searchResult.AlbumArtist = artistResults[0]; + searchResult.Artists = artistResults; } searchResult.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseSearchResult.Id.ToString()); diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 718b5a1c46..1323d2604a 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -8,8 +8,10 @@ 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; @@ -23,8 +25,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz; public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable { private readonly ILogger<MusicBrainzArtistProvider> _logger; - private readonly Query _musicBrainzQuery; - private readonly string _musicBrainzDefaultUri = "https://musicbrainz.org"; + private Query _musicBrainzQuery; /// <summary> /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class. @@ -33,34 +34,37 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar public MusicBrainzArtistProvider(ILogger<MusicBrainzArtistProvider> logger) { _logger = logger; - - MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) => - { - if (Uri.TryCreate(MusicBrainz.Plugin.Instance.Configuration.Server, UriKind.Absolute, out var server)) - { - Query.DefaultServer = server.Host; - 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(_musicBrainzDefaultUri); - Query.DefaultServer = defaultServer.Host; - Query.DefaultPort = defaultServer.Port; - Query.DefaultUrlScheme = defaultServer.Scheme; - } - - Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit; - }; - _musicBrainzQuery = new Query(); + ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration); + MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig; } /// <inheritdoc /> public string Name => "MusicBrainz"; + 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) { @@ -112,7 +116,8 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar { Name = artist.Name, ProductionYear = artist.LifeSpan?.Begin?.Year, - PremiereDate = artist.LifeSpan?.Begin?.NearestDate + PremiereDate = artist.LifeSpan?.Begin?.NearestDate, + SearchProviderName = Name, }; searchResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString()); diff --git a/MediaBrowser.Providers/Plugins/Omdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Omdb/Configuration/config.html index f4375b3cb5..d00c1f9f8e 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Omdb/Configuration/config.html @@ -4,9 +4,10 @@ <title>OMDb</title> </head> <body> - <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> + <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> <div data-role="content"> <div class="content-primary"> + <h1>OMDb</h1> <form class="configForm"> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="castAndCrew" /> @@ -33,16 +34,16 @@ }); }); - + document.querySelector('.configForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); - + ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { config.CastAndCrew = document.querySelector('#castAndCrew').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); - + e.preventDefault(); return false; }); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs index 60b373483f..140a64f52f 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs @@ -38,10 +38,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> - { - ImageType.Primary - }; + yield return ImageType.Primary; } public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 497437bd8a..3fd4ae1fc0 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -13,6 +13,7 @@ using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; @@ -98,8 +99,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb // item.VoteCount = voteCount; } - if (!string.IsNullOrEmpty(result.imdbRating) - && float.TryParse(result.imdbRating, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbRating) + if (float.TryParse(result.imdbRating, CultureInfo.InvariantCulture, out var imdbRating) && imdbRating >= 0) { item.CommunityRating = imdbRating; @@ -209,8 +209,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb // item.VoteCount = voteCount; } - if (!string.IsNullOrEmpty(result.imdbRating) - && float.TryParse(result.imdbRating, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbRating) + if (float.TryParse(result.imdbRating, CultureInfo.InvariantCulture, out var imdbRating) && imdbRating >= 0) { item.CommunityRating = imdbRating; @@ -426,7 +425,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var person = new PersonInfo { Name = result.Director, - Type = PersonType.Director + Type = PersonKind.Director }; itemResult.AddPerson(person); @@ -437,7 +436,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var person = new PersonInfo { Name = result.Writer, - Type = PersonType.Writer + Type = PersonKind.Writer }; itemResult.AddPerson(person); @@ -456,7 +455,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var person = new PersonInfo { Name = actor, - Type = PersonType.Actor + Type = PersonKind.Actor }; itemResult.AddPerson(person); @@ -552,7 +551,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (rating?.Value is not null) { var value = rating.Value.TrimEnd('%'); - if (float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var score)) + if (float.TryParse(value, CultureInfo.InvariantCulture, out var score)) { return score; } diff --git a/MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html index 63750dbcd1..85ebe443fd 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html @@ -4,9 +4,10 @@ <title>Studio Images</title> </head> <body> - <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> + <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> <div data-role="content"> <div class="content-primary"> + <h1>Studio Images</h1> <form class="configForm"> <div class="inputContainer"> <input is="emby-input" type="text" id="repository" label="Repository" /> diff --git a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs index 0fb9d30a62..a8461e9912 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs @@ -53,7 +53,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> + return new ImageType[] { ImageType.Thumb }; @@ -64,7 +64,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages { var thumbsPath = Path.Combine(_config.ApplicationPaths.CachePath, "imagesbyname", "remotestudiothumbs.txt"); - thumbsPath = await EnsureThumbsList(thumbsPath, cancellationToken).ConfigureAwait(false); + await EnsureThumbsList(thumbsPath, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); @@ -107,7 +107,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages return string.Format(CultureInfo.InvariantCulture, "{0}/images/{1}/{2}.jpg", GetRepositoryUrl(), image, filename); } - private Task<string> EnsureThumbsList(string file, CancellationToken cancellationToken) + private Task EnsureThumbsList(string file, CancellationToken cancellationToken) { string url = string.Format(CultureInfo.InvariantCulture, "{0}/thumbs.txt", GetRepositoryUrl()); @@ -129,7 +129,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages /// <param name="fileSystem">The file system.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>A Task to ensure existence of a file listing.</returns> - public async Task<string> EnsureList(string url, string file, IFileSystem fileSystem, CancellationToken cancellationToken) + public async Task EnsureList(string url, string file, IFileSystem fileSystem, CancellationToken cancellationToken) { var fileInfo = fileSystem.GetFileInfo(file); @@ -148,8 +148,6 @@ namespace MediaBrowser.Providers.Plugins.StudioImages } } } - - return file; } /// <summary> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs index ac3df1d5d6..450ee2a337 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs @@ -11,7 +11,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Api /// The TMDb API controller. /// </summary> [ApiController] - [Authorize(Policy = "DefaultAuthorization")] + [Authorize] [Route("[controller]")] [Produces(MediaTypeNames.Application.Json)] public class TmdbController : ControllerBase diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index eee3658de5..a4c6cb47de 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -50,7 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> + return new ImageType[] { ImageType.Primary, ImageType.Backdrop diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index 1cce7fc35a..c2018d820e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -74,7 +72,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, cancellationToken).ConfigureAwait(false); - var collections = new List<RemoteSearchResult>(); + var collections = new RemoteSearchResult[collectionSearchResults.Count]; for (var i = 0; i < collectionSearchResults.Count; i++) { var collection = new RemoteSearchResult @@ -84,7 +82,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets }; collection.SetProviderId(MetadataProvider.Tmdb, collectionSearchResults[i].Id.ToString(CultureInfo.InvariantCulture)); - collections.Add(collection); + collections[i] = collection; } return collections; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 48ec0535c5..cd21516f95 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -4,9 +4,10 @@ <title>TMDb</title> </head> <body> - <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> + <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox"> <div data-role="content"> <div class="content-primary"> + <h1>TMDb</h1> <form class="configForm"> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="includeAdult" /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index 02601d3f56..bfec48e7c7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -51,7 +49,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> + return new ImageType[] { ImageType.Primary, ImageType.Backdrop, diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 9eced93fa5..2f62e117eb 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -7,6 +5,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; @@ -64,32 +63,35 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies cancellationToken) .ConfigureAwait(false); - var remoteResult = new RemoteSearchResult + if (movie is not null) { - Name = movie.Title ?? movie.OriginalTitle, - SearchProviderName = Name, - ImageUrl = _tmdbClientManager.GetPosterUrl(movie.PosterPath), - Overview = movie.Overview - }; + var remoteResult = new RemoteSearchResult + { + Name = movie.Title ?? movie.OriginalTitle, + SearchProviderName = Name, + ImageUrl = _tmdbClientManager.GetPosterUrl(movie.PosterPath), + Overview = movie.Overview + }; - if (movie.ReleaseDate is not null) - { - var releaseDate = movie.ReleaseDate.Value.ToUniversalTime(); - remoteResult.PremiereDate = releaseDate; - remoteResult.ProductionYear = releaseDate.Year; - } + if (movie.ReleaseDate is not null) + { + var releaseDate = movie.ReleaseDate.Value.ToUniversalTime(); + remoteResult.PremiereDate = releaseDate; + remoteResult.ProductionYear = releaseDate.Year; + } - remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture)); + remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture)); - if (!string.IsNullOrWhiteSpace(movie.ImdbId)) - { - remoteResult.SetProviderId(MetadataProvider.Imdb, movie.ImdbId); - } + if (!string.IsNullOrWhiteSpace(movie.ImdbId)) + { + remoteResult.SetProviderId(MetadataProvider.Imdb, movie.ImdbId); + } - return new[] { remoteResult }; + return new[] { remoteResult }; + } } - IReadOnlyList<SearchMovie> movieResults; + IReadOnlyList<SearchMovie>? movieResults = null; if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id)) { var result = await _tmdbClientManager.FindByExternalIdAsync( @@ -97,18 +99,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies FindExternalSource.Imdb, TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), cancellationToken).ConfigureAwait(false); - movieResults = result.MovieResults; + movieResults = result?.MovieResults; } - else if (searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out id)) + + if (movieResults is null && searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out id)) { var result = await _tmdbClientManager.FindByExternalIdAsync( id, FindExternalSource.TvDb, TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), cancellationToken).ConfigureAwait(false); - movieResults = result.MovieResults; + movieResults = result?.MovieResults; } - else + + if (movieResults is null) { movieResults = await _tmdbClientManager .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, cancellationToken) @@ -255,7 +259,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies { Name = actor.Name.Trim(), Role = actor.Character, - Type = PersonType.Actor, + Type = PersonKind.Actor, SortOrder = actor.Order }; @@ -275,20 +279,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieResult.Credits?.Crew is not null) { - var keepTypes = new[] - { - PersonType.Director, - PersonType.Writer, - PersonType.Producer - }; - foreach (var person in movieResult.Credits.Crew) { // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!keepTypes.Contains(type, StringComparison.OrdinalIgnoreCase) && - !keepTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + if (!TmdbUtils.WantedCrewKinds.Contains(type) + && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index bc959ee2bd..9e5404b325 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -46,10 +46,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> - { - ImageType.Primary - }; + yield return ImageType.Primary; } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index b3709baf58..5c6e71fd89 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -69,7 +67,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People var personSearchResult = await _tmdbClientManager.SearchPersonAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false); - var remoteSearchResults = new List<RemoteSearchResult>(); + var remoteSearchResults = new RemoteSearchResult[personSearchResult.Count]; for (var i = 0; i < personSearchResult.Count; i++) { var person = personSearchResult[i]; @@ -81,7 +79,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People }; remoteSearchResult.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture)); - remoteSearchResults.Add(remoteSearchResult); + remoteSearchResults[i] = remoteSearchResult; } return remoteSearchResults; @@ -107,6 +105,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People if (personTmdbId > 0) { var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + if (person is null) + { + return result; + } result.HasMetadata = true; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 5259faf76f..d1fec7cb13 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -49,10 +47,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> - { - ImageType.Primary - }; + yield return ImageType.Primary; } /// <inheritdoc /> @@ -63,7 +58,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); - if (seriesTmdbId <= 0) + if (series is null || seriesTmdbId <= 0) { return Enumerable.Empty<RemoteImageInfo>(); } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 35e304a2ac..f18575aa98 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -7,6 +5,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; @@ -87,7 +86,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return metadataResult; } - info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string tmdbId); + info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? tmdbId); var seriesTmdbId = Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture); if (seriesTmdbId <= 0) @@ -170,7 +169,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { Name = actor.Name.Trim(), Role = actor.Character, - Type = PersonType.Actor, + Type = PersonKind.Actor, SortOrder = actor.Order }); } @@ -184,7 +183,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { Name = guest.Name.Trim(), Role = guest.Character, - Type = PersonType.GuestStar, + Type = PersonKind.GuestStar, SortOrder = guest.Order }); } @@ -198,7 +197,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + if (!TmdbUtils.WantedCrewKinds.Contains(type) && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index b8d1460db9..a743601ed3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -48,10 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> - { - ImageType.Primary - }; + yield return ImageType.Primary; } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 3cb72b89b5..10efb68b94 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; @@ -88,7 +89,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { Name = cast[i].Name.Trim(), Role = cast[i].Character, - Type = PersonType.Actor, + Type = PersonKind.Actor, SortOrder = cast[i].Order }); } @@ -101,7 +102,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!TmdbUtils.WantedCrewTypes.Contains(type, StringComparison.OrdinalIgnoreCase) + if (!TmdbUtils.WantedCrewKinds.Contains(type) && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 79cb6e86d4..192fb052d7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -48,7 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> + return new ImageType[] { ImageType.Primary, ImageType.Backdrop, diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 9590882105..8dc2d69385 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -7,6 +5,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; @@ -211,7 +210,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - if (string.IsNullOrEmpty(tmdbId)) + if (!int.TryParse(tmdbId, CultureInfo.InvariantCulture, out int tmdbIdInt)) { return result; } @@ -219,9 +218,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV cancellationToken.ThrowIfCancellationRequested(); var tvShow = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) .ConfigureAwait(false); + if (tvShow is null) + { + return result; + } + result = new MetadataResult<Series> { Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode), @@ -349,7 +353,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { Name = actor.Name.Trim(), Role = actor.Character, - Type = PersonType.Actor, + Type = PersonKind.Actor, SortOrder = actor.Order, ImageUrl = _tmdbClientManager.GetPosterUrl(actor.ProfilePath) }; @@ -377,8 +381,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Normalize this var type = TmdbUtils.MapCrewToPersonType(person); - if (!keepTypes.Contains(type, StringComparison.OrdinalIgnoreCase) - && !keepTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + if (!TmdbUtils.WantedCrewKinds.Contains(type) + && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index c7441bf357..500ebaf71c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -1,6 +1,4 @@ -#nullable disable - -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Threading; @@ -50,10 +48,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb movie or null if not found.</returns> - public async Task<Movie> GetMovieAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken) + public async Task<Movie?> GetMovieAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) { var key = $"movie-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; - if (_memoryCache.TryGetValue(key, out Movie movie)) + if (_memoryCache.TryGetValue(key, out Movie? movie)) { return movie; } @@ -89,10 +87,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb collection or null if not found.</returns> - public async Task<Collection> GetCollectionAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken) + public async Task<Collection?> GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) { var key = $"collection-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; - if (_memoryCache.TryGetValue(key, out Collection collection)) + if (_memoryCache.TryGetValue(key, out Collection? collection)) { return collection; } @@ -122,10 +120,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show information or null if not found.</returns> - public async Task<TvShow> GetSeriesAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken) + public async Task<TvShow?> GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) { var key = $"series-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; - if (_memoryCache.TryGetValue(key, out TvShow series)) + if (_memoryCache.TryGetValue(key, out TvShow? series)) { return series; } @@ -162,7 +160,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show episode group information or null if not found.</returns> - private async Task<TvGroupCollection> GetSeriesGroupAsync(int tvShowId, string displayOrder, string language, string imageLanguages, CancellationToken cancellationToken) + private async Task<TvGroupCollection?> GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) { TvGroupType? groupType = string.Equals(displayOrder, "originalAirDate", StringComparison.Ordinal) ? TvGroupType.OriginalAirDate : @@ -180,7 +178,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } var key = $"group-{tvShowId.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; - if (_memoryCache.TryGetValue(key, out TvGroupCollection group)) + if (_memoryCache.TryGetValue(key, out TvGroupCollection? group)) { return group; } @@ -217,10 +215,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv season information or null if not found.</returns> - public async Task<TvSeason> GetSeasonAsync(int tvShowId, int seasonNumber, string language, string imageLanguages, CancellationToken cancellationToken) + public async Task<TvSeason?> GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, CancellationToken cancellationToken) { var key = $"season-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; - if (_memoryCache.TryGetValue(key, out TvSeason season)) + if (_memoryCache.TryGetValue(key, out TvSeason? season)) { return season; } @@ -254,10 +252,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguages">A comma-separated list of image languages.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv episode information or null if not found.</returns> - public async Task<TvEpisode> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string language, string imageLanguages, CancellationToken cancellationToken) + public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) { var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; - if (_memoryCache.TryGetValue(key, out TvEpisode episode)) + if (_memoryCache.TryGetValue(key, out TvEpisode? episode)) { return episode; } @@ -301,10 +299,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="language">The episode's language.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb person information or null if not found.</returns> - public async Task<Person> GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken) + public async Task<Person?> GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken) { var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; - if (_memoryCache.TryGetValue(key, out Person person)) + if (_memoryCache.TryGetValue(key, out Person? person)) { return person; } @@ -333,14 +331,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="language">The item's language.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb item or null if not found.</returns> - public async Task<FindContainer> FindByExternalIdAsync( + public async Task<FindContainer?> FindByExternalIdAsync( string externalId, FindExternalSource source, string language, CancellationToken cancellationToken) { var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}"; - if (_memoryCache.TryGetValue(key, out FindContainer result)) + if (_memoryCache.TryGetValue(key, out FindContainer? result)) { return result; } @@ -372,7 +370,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default) { var key = $"searchseries-{name}-{language}"; - if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv> series)) + if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null) { return series.Results; } @@ -400,7 +398,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public async Task<IReadOnlyList<SearchPerson>> SearchPersonAsync(string name, CancellationToken cancellationToken) { var key = $"searchperson-{name}"; - if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson> person)) + if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson>? person) && person is not null) { return person.Results; } @@ -442,7 +440,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, CancellationToken cancellationToken) { var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; - if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie> movies)) + if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie>? movies) && movies is not null) { return movies.Results; } @@ -471,7 +469,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, CancellationToken cancellationToken) { var key = $"collectionsearch-{name}-{language}"; - if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection> collections)) + if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection>? collections) && collections is not null) { return collections.Results; } @@ -496,7 +494,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="size">The image size to fetch.</param> /// <param name="path">The relative URL of the image.</param> /// <returns>The absolute URL.</returns> - private string GetUrl(string size, string path) + private string? GetUrl(string? size, string path) { if (string.IsNullOrEmpty(path)) { @@ -511,7 +509,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="posterPath">The relative URL of the poster.</param> /// <returns>The absolute URL.</returns> - public string GetPosterUrl(string posterPath) + public string? GetPosterUrl(string posterPath) { return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath); } @@ -521,7 +519,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="actorProfilePath">The relative URL of the profile image.</param> /// <returns>The absolute URL.</returns> - public string GetProfileUrl(string actorProfilePath) + public string? GetProfileUrl(string actorProfilePath) { return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } @@ -579,7 +577,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="type">The type of the image.</param> /// <param name="requestLanguage">The requested language.</param> /// <returns>The remote images.</returns> - private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string size, ImageType type, string requestLanguage) + private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string? size, ImageType type, string requestLanguage) { // sizes provided are for original resolution, don't store them when downloading scaled images var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 44c2c81f44..516eee758c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; @@ -39,6 +41,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb }; /// <summary> + /// The crew kinds to keep. + /// </summary> + public static readonly PersonKind[] WantedCrewKinds = + { + PersonKind.Director, + PersonKind.Writer, + PersonKind.Producer + }; + + /// <summary> /// Cleans the name according to TMDb requirements. /// </summary> /// <param name="name">The name of the entity.</param> @@ -54,26 +66,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="crew">Crew member to map against the Jellyfin person types.</param> /// <returns>The Jellyfin person type.</returns> - public static string MapCrewToPersonType(Crew crew) + public static PersonKind MapCrewToPersonType(Crew crew) { if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) && crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase)) { - return PersonType.Director; + return PersonKind.Director; } if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) && crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase)) { - return PersonType.Producer; + return PersonKind.Producer; } if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)) { - return PersonType.Writer; + return PersonKind.Writer; } - return string.Empty; + return PersonKind.Unknown; } /// <summary> @@ -128,7 +140,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="language">The language code.</param> /// <returns>The normalized language code.</returns> - public static string NormalizeLanguage(string language) + [return: NotNullIfNotNull(nameof(language))] + public static string? NormalizeLanguage(string? language) { if (string.IsNullOrEmpty(language)) { diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index b1a26cfba3..0c01c50317 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -35,34 +33,30 @@ namespace MediaBrowser.Providers.Subtitles private readonly IMediaSourceManager _mediaSourceManager; private readonly ILocalizationManager _localization; - private ISubtitleProvider[] _subtitleProviders; + private readonly ISubtitleProvider[] _subtitleProviders; public SubtitleManager( ILogger<SubtitleManager> logger, IFileSystem fileSystem, ILibraryMonitor monitor, IMediaSourceManager mediaSourceManager, - ILocalizationManager localizationManager) + ILocalizationManager localizationManager, + IEnumerable<ISubtitleProvider> subtitleProviders) { _logger = logger; _fileSystem = fileSystem; _monitor = monitor; _mediaSourceManager = mediaSourceManager; _localization = localizationManager; - } - - /// <inheritdoc /> - public event EventHandler<SubtitleDownloadFailureEventArgs> SubtitleDownloadFailure; - - /// <inheritdoc /> - public void AddParts(IEnumerable<ISubtitleProvider> subtitleProviders) - { _subtitleProviders = subtitleProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); } /// <inheritdoc /> + public event EventHandler<SubtitleDownloadFailureEventArgs>? SubtitleDownloadFailure; + + /// <inheritdoc /> public async Task<RemoteSubtitleInfo[]> SearchSubtitles(SubtitleSearchRequest request, CancellationToken cancellationToken) { if (request.Language is not null) @@ -197,49 +191,49 @@ namespace MediaBrowser.Providers.Subtitles await stream.CopyToAsync(memoryStream).ConfigureAwait(false); memoryStream.Position = 0; } - } - var savePaths = new List<string>(); - var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); + var savePaths = new List<string>(); + var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); - if (response.IsForced) - { - saveFileName += ".forced"; - } + if (response.IsForced) + { + saveFileName += ".forced"; + } - saveFileName += "." + response.Format.ToLowerInvariant(); + saveFileName += "." + response.Format.ToLowerInvariant(); - if (saveInMediaFolder) - { - var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName)); - // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, bad path."); - if (mediaFolderPath.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal)) + if (saveInMediaFolder) { - savePaths.Add(mediaFolderPath); + var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName)); + // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, bad path."); + if (mediaFolderPath.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal)) + { + savePaths.Add(mediaFolderPath); + } } - } - var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); + var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); - // TODO: Add some error to the user: return BadRequest("Could not save subtitle, bad path."); - if (internalPath.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal)) - { - savePaths.Add(internalPath); - } + // TODO: Add some error to the user: return BadRequest("Could not save subtitle, bad path."); + if (internalPath.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal)) + { + savePaths.Add(internalPath); + } - if (savePaths.Count > 0) - { - await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); - } - else - { - _logger.LogError("An uploaded subtitle could not be saved because the resulting paths were invalid."); + if (savePaths.Count > 0) + { + await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); + } + else + { + _logger.LogError("An uploaded subtitle could not be saved because the resulting paths were invalid."); + } } } private async Task TrySaveToFiles(Stream stream, List<string> savePaths) { - List<Exception> exs = null; + List<Exception>? exs = null; foreach (var savePath in savePaths) { @@ -249,7 +243,7 @@ namespace MediaBrowser.Providers.Subtitles try { - Directory.CreateDirectory(Path.GetDirectoryName(savePath)); + Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory.")); var fileOptions = AsyncFile.WriteOptions; fileOptions.Mode = FileMode.CreateNew; diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index a261d7cdb5..9016e5de0c 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; @@ -43,7 +41,7 @@ namespace MediaBrowser.Providers.TV RemoveObsoleteEpisodes(item); RemoveObsoleteSeasons(item); - await FillInMissingSeasonsAsync(item, cancellationToken).ConfigureAwait(false); + await UpdateAndCreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> @@ -69,6 +67,20 @@ namespace MediaBrowser.Providers.TV var sourceItem = source.Item; var targetItem = target.Item; + var sourceSeasonNames = sourceItem.SeasonNames; + var targetSeasonNames = targetItem.SeasonNames; + + if (replaceData || targetSeasonNames.Count == 0) + { + targetItem.SeasonNames = sourceSeasonNames; + } + else if (targetSeasonNames.Count != sourceSeasonNames.Count || !sourceSeasonNames.Keys.All(targetSeasonNames.ContainsKey)) + { + foreach (var (number, name) in sourceSeasonNames) + { + targetSeasonNames.TryAdd(number, name); + } + } if (replaceData || string.IsNullOrEmpty(targetItem.AirTime)) { @@ -88,7 +100,7 @@ namespace MediaBrowser.Providers.TV private void RemoveObsoleteSeasons(Series series) { - // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in FillInMissingSeasonsAsync. + // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in UpdateAndCreateSeasonsAsync. var physicalSeasonNumbers = new HashSet<int>(); var virtualSeasons = new List<Season>(); foreach (var existingSeason in series.Children.OfType<Season>()) @@ -179,36 +191,43 @@ namespace MediaBrowser.Providers.TV } /// <summary> - /// Creates seasons for all episodes that aren't in a season folder. + /// Creates seasons for all episodes if they don't exist. /// If no season number can be determined, a dummy season will be created. + /// Updates seasons names. /// </summary> /// <param name="series">The series.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The async task.</returns> - private async Task FillInMissingSeasonsAsync(Series series, CancellationToken cancellationToken) + private async Task UpdateAndCreateSeasonsAsync(Series series, CancellationToken cancellationToken) { + var seasonNames = series.SeasonNames; var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); - var episodesInSeriesFolder = seriesChildren + var seasons = seriesChildren.OfType<Season>().ToList(); + var uniqueSeasonNumbers = seriesChildren .OfType<Episode>() - .Where(i => !i.IsInSeasonFolder); - - List<Season> seasons = seriesChildren.OfType<Season>().ToList(); + .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null) + .Distinct(); // Loop through the unique season numbers - foreach (var episode in episodesInSeriesFolder) + foreach (var seasonNumber in uniqueSeasonNumbers) { // Null season numbers will have a 'dummy' season created because seasons are always required. - var seasonNumber = episode.ParentIndexNumber >= 0 ? episode.ParentIndexNumber : null; var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber); + string? seasonName = null; + + if (seasonNumber.HasValue && seasonNames.TryGetValue(seasonNumber.Value, out var tmp)) + { + seasonName = tmp; + } if (existingSeason is null) { - var season = await CreateSeasonAsync(series, seasonNumber, cancellationToken).ConfigureAwait(false); - seasons.Add(season); + var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false); + series.AddChild(season); } - else if (existingSeason.IsVirtualItem) + else { - existingSeason.IsVirtualItem = false; + existingSeason.Name = GetValidSeasonNameForSeries(series, seasonName, seasonNumber); await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); } } @@ -218,21 +237,17 @@ namespace MediaBrowser.Providers.TV /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata. /// </summary> /// <param name="series">The series.</param> + /// <param name="seasonName">The season name.</param> /// <param name="seasonNumber">The season number.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The newly created season.</returns> private async Task<Season> CreateSeasonAsync( Series series, + string? seasonName, int? seasonNumber, CancellationToken cancellationToken) { - string seasonName = seasonNumber switch - { - null => _localizationManager.GetLocalizedString("NameSeasonUnknown"), - 0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName, - _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value) - }; - + seasonName = GetValidSeasonNameForSeries(series, seasonName, seasonNumber); Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name); var season = new Season @@ -253,5 +268,20 @@ namespace MediaBrowser.Providers.TV return season; } + + private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber) + { + if (string.IsNullOrEmpty(seasonName)) + { + seasonName = seasonNumber switch + { + null => _localizationManager.GetLocalizedString("NameSeasonUnknown"), + 0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName, + _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value) + }; + } + + return seasonName; + } } } diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index c25932a5a1..8072349152 100644 --- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj +++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj @@ -22,13 +22,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index c3a735c6de..5b68924acb 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Xml; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Providers; @@ -99,10 +100,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers foreach (var info in idInfos) { var id = info.Key + "Id"; - if (!_validProviderIds.ContainsKey(id)) - { - _validProviderIds.Add(id, info.Key); - } + _validProviderIds.TryAdd(id, info.Key); } // Additional Mappings @@ -274,16 +272,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val)) + if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added)) { - if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added)) - { - item.DateCreated = added; - } - else - { - Logger.LogWarning("Invalid Added value found: {Value}", val); - } + item.DateCreated = added; + } + else + { + Logger.LogWarning("Invalid Added value found: {Value}", val); } break; @@ -315,12 +310,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var text = reader.ReadElementContentAsString(); - if (!string.IsNullOrEmpty(text)) + if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) { - if (float.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) - { - item.CriticRating = value; - } + item.CriticRating = value; } break; @@ -379,15 +371,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "playcount": { var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val) && !string.IsNullOrWhiteSpace(nfoConfiguration.UserId)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) + && Guid.TryParse(nfoConfiguration.UserId, out var guid)) { - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)) - { - var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId)); - userData = _userDataManager.GetUserData(user, item); - userData.PlayCount = count; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); - } + var user = _userManager.GetUserById(guid); + userData = _userDataManager.GetUserData(user, item); + userData.PlayCount = count; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); } break; @@ -396,11 +386,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "lastplayed": { var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val) && !string.IsNullOrWhiteSpace(nfoConfiguration.UserId)) + if (Guid.TryParse(nfoConfiguration.UserId, out var guid)) { if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added)) { - var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId)); + var user = _userManager.GetUserById(guid); userData = _userDataManager.GetUserData(user, item); userData.LastPlayedDate = added; _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); @@ -490,12 +480,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var text = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(text)) + if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) { - if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) - { - item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; - } + item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; } break; @@ -541,7 +528,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "director": { var val = reader.ReadElementContentAsString(); - foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Director })) + foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Director })) { if (string.IsNullOrWhiteSpace(p.Name)) { @@ -563,7 +550,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers var parts = val.Split('/').Select(i => i.Trim()) .Where(i => !string.IsNullOrEmpty(i)); - foreach (var p in parts.Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Writer })) + foreach (var p in parts.Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Writer })) { if (string.IsNullOrWhiteSpace(p.Name)) { @@ -580,7 +567,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "writer": { var val = reader.ReadElementContentAsString(); - foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Writer })) + foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Writer })) { if (string.IsNullOrWhiteSpace(p.Name)) { @@ -633,13 +620,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var val = reader.ReadElementContentAsString(); - var hasDisplayOrder = item as IHasDisplayOrder; - if (hasDisplayOrder is not null) + if (item is IHasDisplayOrder hasDisplayOrder && !string.IsNullOrWhiteSpace(val)) { - if (!string.IsNullOrWhiteSpace(val)) - { - hasDisplayOrder.DisplayOrder = val; - } + hasDisplayOrder.DisplayOrder = val; } break; @@ -649,12 +632,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val)) + if (int.TryParse(val, out var productionYear) && productionYear > 1850) { - if (int.TryParse(val, out var productionYear) && productionYear > 1850) - { - item.ProductionYear = productionYear; - } + item.ProductionYear = productionYear; } break; @@ -664,13 +644,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var rating = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(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)) { - // 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; - } + item.CommunityRating = val; } break; @@ -700,13 +677,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val)) + if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) { - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) - { - item.PremiereDate = date; - item.ProductionYear = date.Year; - } + item.PremiereDate = date; + item.ProductionYear = date.Year; } break; @@ -718,12 +692,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val)) + if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) { - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) - { - item.EndDate = date; - } + item.EndDate = date; } break; @@ -1194,21 +1165,21 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "value": var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val)) + if (float.TryParse(val, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var ratingValue)) { - if (float.TryParse(val, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var ratingValue)) + // if ratingName contains tomato --> assume critic rating + if (ratingName is not null + && ratingName.Contains("tomato", StringComparison.OrdinalIgnoreCase) + && !ratingName.Contains("audience", StringComparison.OrdinalIgnoreCase)) { - // if ratingName contains tomato --> assume critic rating - if (ratingName is not null && - ratingName.Contains("tomato", StringComparison.OrdinalIgnoreCase) && - !ratingName.Contains("audience", StringComparison.OrdinalIgnoreCase)) + if (!ratingName.Contains("avg", StringComparison.OrdinalIgnoreCase)) { item.CriticRating = ratingValue; } - else - { - item.CommunityRating = ratingValue; - } + } + else + { + item.CommunityRating = ratingValue; } } @@ -1233,7 +1204,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers private PersonInfo GetPersonFromXmlNode(XmlReader reader) { var name = string.Empty; - var type = PersonType.Actor; // If type is not specified assume actor + var type = PersonKind.Actor; // If type is not specified assume actor var role = string.Empty; int? sortOrder = null; string? imageUrl = null; @@ -1267,21 +1238,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "type": { var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) + if (!Enum.TryParse(val, true, out type)) { - type = val switch - { - PersonType.Composer => PersonType.Composer, - PersonType.Conductor => PersonType.Conductor, - PersonType.Director => PersonType.Director, - PersonType.Lyricist => PersonType.Lyricist, - PersonType.Producer => PersonType.Producer, - PersonType.Writer => PersonType.Writer, - PersonType.GuestStar => PersonType.GuestStar, - // unknown type --> actor - _ => PersonType.Actor - }; + type = PersonKind.Actor; } break; @@ -1292,12 +1251,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val)) + if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) { - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) - { - sortOrder = intVal; - } + sortOrder = intVal; } break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs index 2f5fd40e2f..51d5f932bc 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs @@ -55,6 +55,18 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "seasonname": + { + var name = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(name)) + { + item.Name = name; + } + + break; + } + default: base.FetchDataFromXmlNode(reader, itemResult); break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs index 3011d65a6d..f22b861eba 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; @@ -110,6 +112,19 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + case "namedseason": + { + var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber); + var name = reader.ReadElementContentAsString(); + + if (!string.IsNullOrWhiteSpace(name) && parsed) + { + item.SeasonNames[seasonNumber] = name; + } + + break; + } + default: base.FetchDataFromXmlNode(reader, itemResult); break; diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 130d0bfe40..4f8f869ace 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Xml; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; @@ -485,7 +486,7 @@ namespace MediaBrowser.XbmcMetadata.Savers var people = libraryManager.GetPeople(item); var directors = people - .Where(i => IsPersonType(i, PersonType.Director)) + .Where(i => i.IsType(PersonKind.Director)) .Select(i => i.Name) .ToList(); @@ -495,7 +496,7 @@ namespace MediaBrowser.XbmcMetadata.Savers } var writers = people - .Where(i => IsPersonType(i, PersonType.Writer)) + .Where(i => i.IsType(PersonKind.Writer)) .Select(i => i.Name) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); @@ -913,7 +914,7 @@ namespace MediaBrowser.XbmcMetadata.Savers { foreach (var person in people) { - if (IsPersonType(person, PersonType.Director) || IsPersonType(person, PersonType.Writer)) + if (person.IsType(PersonKind.Director) || person.IsType(PersonKind.Writer)) { continue; } @@ -930,9 +931,9 @@ namespace MediaBrowser.XbmcMetadata.Savers writer.WriteElementString("role", person.Role); } - if (!string.IsNullOrWhiteSpace(person.Type)) + if (person.Type != PersonKind.Unknown) { - writer.WriteElementString("type", person.Type); + writer.WriteElementString("type", person.Type.ToString()); } if (person.SortOrder.HasValue) @@ -969,10 +970,6 @@ namespace MediaBrowser.XbmcMetadata.Savers return libraryManager.GetPathAfterNetworkSubstitution(image.Path); } - private bool IsPersonType(PersonInfo person, string type) - => string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) - || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase); - private void AddCustomTags(string path, IReadOnlyCollection<string> xmlTagsUsed, XmlWriter writer, ILogger<BaseNfoSaver> logger) { var settings = new XmlReaderSettings() diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs index 21e7e2335b..82e1dc860d 100644 --- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs @@ -62,7 +62,8 @@ namespace MediaBrowser.XbmcMetadata.Savers { yield return Path.ChangeExtension(item.Path, ".nfo"); - if (!item.IsInMixedFolder) + // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie) + if (!item.IsInMixedFolder && item.ItemType == typeof(Movie)) { yield return Path.Combine(item.ContainingFolderPath, "movie.nfo"); } diff --git a/RSSDP/HttpParserBase.cs b/RSSDP/HttpParserBase.cs index 6b6c13d996..1949a9df33 100644 --- a/RSSDP/HttpParserBase.cs +++ b/RSSDP/HttpParserBase.cs @@ -221,10 +221,8 @@ namespace Rssdp.Infrastructure { return trimmedSegment.Substring(1, trimmedSegment.Length - 2); } - else - { - return trimmedSegment; - } + + return trimmedSegment; } } } diff --git a/RSSDP/HttpRequestParser.cs b/RSSDP/HttpRequestParser.cs index a3e100796d..a1b4627a93 100644 --- a/RSSDP/HttpRequestParser.cs +++ b/RSSDP/HttpRequestParser.cs @@ -64,8 +64,7 @@ namespace Rssdp.Infrastructure } message.Method = new HttpMethod(parts[0].Trim()); - Uri requestUri; - if (Uri.TryCreate(parts[1].Trim(), UriKind.RelativeOrAbsolute, out requestUri)) + if (Uri.TryCreate(parts[1].Trim(), UriKind.RelativeOrAbsolute, out var requestUri)) { message.RequestUri = requestUri; } diff --git a/RSSDP/HttpResponseParser.cs b/RSSDP/HttpResponseParser.cs index 3e361465d7..71b7a7b990 100644 --- a/RSSDP/HttpResponseParser.cs +++ b/RSSDP/HttpResponseParser.cs @@ -77,8 +77,7 @@ namespace Rssdp.Infrastructure message.Version = ParseHttpVersion(parts[0].Trim()); - int statusCode = -1; - if (!Int32.TryParse(parts[1].Trim(), out statusCode)) + if (!Int32.TryParse(parts[1].Trim(), out var statusCode)) { throw new ArgumentException("data status line is invalid. Status code is not a valid integer.", nameof(data)); } diff --git a/RSSDP/SsdpDevice.cs b/RSSDP/SsdpDevice.cs index c826830f1d..3e4261b6a9 100644 --- a/RSSDP/SsdpDevice.cs +++ b/RSSDP/SsdpDevice.cs @@ -171,10 +171,8 @@ namespace Rssdp { return "uuid:" + this.Uuid; } - else - { - return _Udn; - } + + return _Udn; } set diff --git a/RSSDP/SsdpDeviceLocator.cs b/RSSDP/SsdpDeviceLocator.cs index 681ef0a5c1..7afd325819 100644 --- a/RSSDP/SsdpDeviceLocator.cs +++ b/RSSDP/SsdpDeviceLocator.cs @@ -483,8 +483,7 @@ namespace Rssdp.Infrastructure } } - Uri retVal; - Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out retVal); + Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var retVal); return retVal; } @@ -501,8 +500,7 @@ namespace Rssdp.Infrastructure } } - Uri retVal; - Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out retVal); + Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var retVal); return retVal; } @@ -587,10 +585,8 @@ namespace Rssdp.Infrastructure { return OneSecond; } - else - { - return searchWaitTime.Subtract(OneSecond); - } + + return searchWaitTime.Subtract(OneSecond); } private DiscoveredSsdpDevice FindExistingDeviceNotification(IEnumerable<DiscoveredSsdpDevice> devices, string notificationType, string usn) diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs index a7767b3c04..be66f5947d 100644 --- a/RSSDP/SsdpDevicePublisher.cs +++ b/RSSDP/SsdpDevicePublisher.cs @@ -244,7 +244,6 @@ namespace Rssdp.Infrastructure // Wait on random interval up to MX, as per SSDP spec. // Also, as per UPnP 1.1/SSDP spec ignore missing/bank MX header. If over 120, assume random value between 0 and 120. // Using 16 as minimum as that's often the minimum system clock frequency anyway. - int maxWaitInterval = 0; if (String.IsNullOrEmpty(mx)) { // Windows Explorer is poorly behaved and doesn't supply an MX header value. @@ -254,7 +253,7 @@ namespace Rssdp.Infrastructure // return; } - if (!Int32.TryParse(mx, out maxWaitInterval) || maxWaitInterval <= 0) + if (!Int32.TryParse(mx, out var maxWaitInterval) || maxWaitInterval <= 0) { return; } @@ -572,17 +571,14 @@ namespace Rssdp.Infrastructure { return nonzeroCacheLifetimesQuery.Min(); } - else - { - return TimeSpan.Zero; - } + + return TimeSpan.Zero; } private string GetFirstHeaderValue(System.Net.Http.Headers.HttpRequestHeaders httpRequestHeaders, string headerName) { string retVal = null; - IEnumerable<String> values = null; - if (httpRequestHeaders.TryGetValues(headerName, out values) && values != null) + if (httpRequestHeaders.TryGetValues(headerName, out var values) && values != null) { retVal = values.FirstOrDefault(); } @@ -644,7 +640,7 @@ namespace Rssdp.Infrastructure public string Key { - get { return this.SearchTarget + ":" + this.EndPoint.ToString(); } + get { return this.SearchTarget + ":" + this.EndPoint; } } public bool IsOld() diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index e02087a525..771675519d 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -13,7 +13,7 @@ RUN yum update -yq \ && yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git wget # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 6962b6bc18..c552f06b0b 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -1,4 +1,4 @@ -FROM fedora:36 +FROM fedora:39 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist @@ -12,7 +12,7 @@ RUN dnf update -yq \ && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget make # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 96e3ca403b..30100d20d9 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -17,7 +17,7 @@ RUN apt-get update -yqq \ libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index f1c5363999..bac2adfafb 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index eaea305d1e..37a1ed5ff3 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c646b288-5d5b-4c9c-a95b-e1fad1c0d95d/e13d71d48b629fe3a85f5676deb09e2d/dotnet-sdk-7.0.102-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj index 51df09a210..1e3f8a0482 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -16,10 +16,10 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.17.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="SharpFuzz" Version="2.0.1" /> + <PackageReference Include="AutoFixture" /> + <PackageReference Include="AutoFixture.AutoMoq" /> + <PackageReference Include="Moq" /> + <PackageReference Include="SharpFuzz" /> </ItemGroup> </Project> diff --git a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj index 226ab60daa..20bc4c7244 100644 --- a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj +++ b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj @@ -16,7 +16,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="SharpFuzz" Version="2.0.1" /> + <PackageReference Include="SharpFuzz" /> </ItemGroup> </Project> diff --git a/nuget.config b/nuget.config deleted file mode 100644 index 326331f322..0000000000 --- a/nuget.config +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<configuration> - <packageSources> - <add key="NuGet official package source" value="https://api.nuget.org/v3/index.json" /> - </packageSources> -</configuration> diff --git a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index a62ebf78c7..0346913226 100644 --- a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -16,11 +16,13 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="BlurHashSharp" Version="1.2.0" /> - <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.2.0" /> - <PackageReference Include="SkiaSharp" Version="2.88.3" /> - <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" /> - <PackageReference Include="SkiaSharp.Svg" Version="1.60.0" /> + <PackageReference Include="BlurHashSharp" /> + <PackageReference Include="BlurHashSharp.SkiaSharp" /> + <PackageReference Include="SkiaSharp" /> + <PackageReference Include="SkiaSharp.NativeAssets.Linux" /> + <PackageReference Include="SkiaSharp.Svg" /> + <PackageReference Include="SkiaSharp.HarfBuzz" /> + <PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" /> </ItemGroup> <ItemGroup> @@ -31,13 +33,13 @@ <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 6da77ad959..2d980db181 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -120,8 +120,18 @@ public class SkiaEncoder : IImageEncoder if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase)) { var svg = new SKSvg(); - svg.Load(path); - return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height)); + try + { + svg.Load(path); + return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height)); + } + catch (FormatException skiaColorException) + { + // This exception is known to be thrown on vector images that define custom styles + // Skia SVG is not able to handle that and as the repository is quite stale and has not received updates we just catch them + _logger.LogDebug(skiaColorException, "There was a issue loading the requested svg file"); + return default; + } } using var codec = SKCodec.Create(path, out SKCodecResult result); @@ -132,10 +142,10 @@ public class SkiaEncoder : IImageEncoder return new ImageDimensions(info.Width, info.Height); case SKCodecResult.Unimplemented: _logger.LogDebug("Image format not supported: {FilePath}", path); - return new ImageDimensions(0, 0); + return default; default: _logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result); - return new ImageDimensions(0, 0); + return default; } } diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index eee24c4236..a7a3338df4 100644 --- a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -3,13 +3,14 @@ using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using SkiaSharp; +using SkiaSharp.HarfBuzz; namespace Jellyfin.Drawing.Skia; /// <summary> /// Used to build collages of multiple images arranged in vertical strips. /// </summary> -public class StripCollageBuilder +public partial class StripCollageBuilder { private readonly SkiaEncoder _skiaEncoder; @@ -22,6 +23,9 @@ public class StripCollageBuilder _skiaEncoder = skiaEncoder; } + [GeneratedRegex(@"\p{IsArabic}|\p{IsArmenian}|\p{IsHebrew}|\p{IsSyriac}|\p{IsThaana}")] + private static partial Regex IsRtlTextRegex(); + /// <summary> /// Check which format an image has been encoded with using its filename extension. /// </summary> @@ -144,7 +148,19 @@ public class StripCollageBuilder textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth; } - canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + if (string.IsNullOrWhiteSpace(libraryName)) + { + return bitmap; + } + + if (IsRtlTextRegex().IsMatch(libraryName)) + { + canvas.DrawShapedText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + } + else + { + canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + } return bitmap; } diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 533baba4fa..4e5d3b4d55 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -50,14 +50,12 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable /// <param name="appPaths">The server application paths.</param> /// <param name="fileSystem">The filesystem.</param> /// <param name="imageEncoder">The image encoder.</param> - /// <param name="mediaEncoder">The media encoder.</param> /// <param name="config">The configuration.</param> public ImageProcessor( ILogger<ImageProcessor> logger, IServerApplicationPaths appPaths, IFileSystem fileSystem, IImageEncoder imageEncoder, - IMediaEncoder mediaEncoder, IServerConfigurationManager config) { _logger = logger; diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 7aa9945033..e0963ac34e 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -23,13 +23,13 @@ <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/src/Jellyfin.Extensions/AlphanumericComparator.cs b/src/Jellyfin.Extensions/AlphanumericComparator.cs index 6e451d40e9..299e2f94ae 100644 --- a/src/Jellyfin.Extensions/AlphanumericComparator.cs +++ b/src/Jellyfin.Extensions/AlphanumericComparator.cs @@ -20,11 +20,13 @@ namespace Jellyfin.Extensions { return 0; } - else if (s1 is null) + + if (s1 is null) { return -1; } - else if (s2 is null) + + if (s2 is null) { return 1; } @@ -37,11 +39,13 @@ namespace Jellyfin.Extensions { return 0; } - else if (len1 == 0) + + if (len1 == 0) { return -1; } - else if (len2 == 0) + + if (len2 == 0) { return 1; } @@ -82,7 +86,8 @@ namespace Jellyfin.Extensions { return -1; } - else if (span1Len > span2Len) + + if (span1Len > span2Len) { return 1; } diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index d7c05ea576..4f80aa9416 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -29,18 +29,18 @@ <ItemGroup> - <PackageReference Include="Diacritics" Version="3.3.14" /> + <PackageReference Include="Diacritics" /> </ItemGroup> <!-- Code Analyzers--> - <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> </Project> diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonBoolStringConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonBoolStringConverter.cs new file mode 100644 index 0000000000..6895eadb86 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonBoolStringConverter.cs @@ -0,0 +1,33 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Jellyfin.Extensions.Json.Converters; + +/// <summary> +/// Converts a string to a boolean. +/// This is needed for FFprobe. +/// </summary> +public class JsonBoolStringConverter : JsonConverter<bool> +{ + /// <inheritdoc /> + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + ReadOnlySpan<byte> utf8Span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + if (Utf8Parser.TryParse(utf8Span, out bool val, out _, 'l')) + { + return val; + } + } + + return reader.GetBoolean(); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + => writer.WriteBooleanValue(value); +} diff --git a/src/Jellyfin.Extensions/Json/JsonDefaults.cs b/src/Jellyfin.Extensions/Json/JsonDefaults.cs index 97cbee9710..4d56ca6151 100644 --- a/src/Jellyfin.Extensions/Json/JsonDefaults.cs +++ b/src/Jellyfin.Extensions/Json/JsonDefaults.cs @@ -39,7 +39,6 @@ namespace Jellyfin.Extensions.Json new JsonFlagEnumConverterFactory(), new JsonStringEnumConverter(), new JsonNullableStructConverterFactory(), - new JsonBoolNumberConverter(), new JsonDateTimeConverter(), new JsonStringConverter() } diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index f30b639459..b22eb7c4ea 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -1,6 +1,4 @@ using System; -using System.Globalization; -using System.Text; using System.Text.RegularExpressions; namespace Jellyfin.Extensions @@ -12,7 +10,7 @@ namespace Jellyfin.Extensions { // Matches non-conforming unicode chars // https://mnaoumov.wordpress.com/2014/06/14/stripping-invalid-characters-from-utf-16-strings/ - private static readonly Regex _nonConformingUnicode = new Regex("([\ud800-\udbff](?![\udc00-\udfff]))|((?<![\ud800-\udbff])[\udc00-\udfff])|(\ufffd)"); + private static readonly Regex _nonConformingUnicode = new Regex("([\ud800-\udbff](?![\udc00-\udfff]))|((?<![\ud800-\udbff])[\udc00-\udfff])|(\ufffd)", RegexOptions.Compiled); /// <summary> /// Removes the diacritics character from the strings. diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index 9a025d5586..3f4f55ee41 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj +++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj @@ -6,14 +6,14 @@ </PropertyGroup> <!-- Code Analyzers--> - <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> @@ -23,7 +23,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> </ItemGroup> <ItemGroup> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index fe4e576937..71572bcf6a 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -6,22 +6,22 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="NEbml" Version="0.11.0" /> + <PackageReference Include="NEbml" /> </ItemGroup> <!-- Code Analyzers--> - <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> </ItemGroup> <ItemGroup> diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000000..de8fc1bb8b --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,23 @@ +<Project> + <!-- Sets defaults for all test projects --> + + <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" /> + + <PropertyGroup> + <TargetFramework>net7.0</TargetFramework> + <IsPackable>false</IsPackable> + <CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)/jellyfin-tests.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + + <!-- Code Analyzers --> + <ItemGroup> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> + <PackageReference Include="SerilogAnalyzer" PrivateAssets="All" /> + <PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" /> + <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" /> + </ItemGroup> + +</Project> diff --git a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs index 7c85ddd620..ad8a051fdc 100644 --- a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs @@ -1,9 +1,13 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; using Jellyfin.Server.Implementations.Security; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; @@ -51,6 +55,32 @@ namespace Jellyfin.Api.Tests.Auth.DefaultAuthorizationPolicy Assert.True(context.HasSucceeded); } + [Fact] + public async Task ShouldSucceedOnApiKey() + { + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); + + _httpContextAccessor + .Setup(h => h.HttpContext!.Connection.RemoteIpAddress) + .Returns(new IPAddress(0)); + + _userManagerMock + .Setup(u => u.GetUserById(It.IsAny<Guid>())) + .Returns<User>(null); + + var claims = new[] + { + new Claim(InternalClaimTypes.IsApiKey, bool.TrueString) + }; + + var identity = new ClaimsIdentity(claims, string.Empty); + var principal = new ClaimsPrincipal(identity); + var context = new AuthorizationHandlerContext(_requirements, principal, null); + + await _sut.HandleAsync(context); + Assert.True(context.HasSucceeded); + } + [Theory] [MemberData(nameof(GetParts_ValidAuthHeader_Success_Data))] public void GetParts_ValidAuthHeader_Success(string input, Dictionary<string, string> parts) diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs index ee42216e46..1ea1797ba1 100644 --- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; -using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; +using Jellyfin.Api.Auth.FirstTimeSetupPolicy; using Jellyfin.Api.Constants; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; @@ -11,25 +11,25 @@ using Microsoft.AspNetCore.Http; using Moq; using Xunit; -namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy +namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy { - public class FirstTimeSetupOrElevatedHandlerTests + public class FirstTimeSetupHandlerTests { private readonly Mock<IConfigurationManager> _configurationManagerMock; private readonly List<IAuthorizationRequirement> _requirements; - private readonly FirstTimeSetupOrElevatedHandler _sut; + private readonly FirstTimeSetupHandler _firstTimeSetupHandler; private readonly Mock<IUserManager> _userManagerMock; private readonly Mock<IHttpContextAccessor> _httpContextAccessor; - public FirstTimeSetupOrElevatedHandlerTests() + public FirstTimeSetupHandlerTests() { var fixture = new Fixture().Customize(new AutoMoqCustomization()); _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); - _requirements = new List<IAuthorizationRequirement> { new FirstTimeSetupOrElevatedRequirement() }; + _requirements = new List<IAuthorizationRequirement> { new FirstTimeSetupRequirement() }; _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); - _sut = fixture.Create<FirstTimeSetupOrElevatedHandler>(); + _firstTimeSetupHandler = fixture.Create<FirstTimeSetupHandler>(); } [Theory] @@ -46,7 +46,7 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy var context = new AuthorizationHandlerContext(_requirements, claims, null); - await _sut.HandleAsync(context); + await _firstTimeSetupHandler.HandleAsync(context); Assert.True(context.HasSucceeded); } @@ -64,7 +64,7 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy var context = new AuthorizationHandlerContext(_requirements, claims, null); - await _sut.HandleAsync(context); + await _firstTimeSetupHandler.HandleAsync(context); Assert.Equal(shouldSucceed, context.HasSucceeded); } } diff --git a/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs index 7150c90bb8..9cf8f85483 100644 --- a/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; -using Jellyfin.Api.Auth.IgnoreParentalControlPolicy; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; @@ -20,7 +20,7 @@ namespace Jellyfin.Api.Tests.Auth.IgnoreSchedulePolicy { private readonly Mock<IConfigurationManager> _configurationManagerMock; private readonly List<IAuthorizationRequirement> _requirements; - private readonly IgnoreParentalControlHandler _sut; + private readonly DefaultAuthorizationHandler _sut; private readonly Mock<IUserManager> _userManagerMock; private readonly Mock<IHttpContextAccessor> _httpContextAccessor; @@ -33,11 +33,11 @@ namespace Jellyfin.Api.Tests.Auth.IgnoreSchedulePolicy { var fixture = new Fixture().Customize(new AutoMoqCustomization()); _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); - _requirements = new List<IAuthorizationRequirement> { new IgnoreParentalControlRequirement() }; + _requirements = new List<IAuthorizationRequirement> { new DefaultAuthorizationRequirement(validateParentalSchedule: false) }; _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); - _sut = fixture.Create<IgnoreParentalControlHandler>(); + _sut = fixture.Create<DefaultAuthorizationHandler>(); } [Theory] diff --git a/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs deleted file mode 100644 index 5b3d784ffa..0000000000 --- a/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading.Tasks; -using AutoFixture; -using AutoFixture.AutoMoq; -using Jellyfin.Api.Auth.LocalAccessPolicy; -using Jellyfin.Api.Constants; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Moq; -using Xunit; - -namespace Jellyfin.Api.Tests.Auth.LocalAccessPolicy -{ - public class LocalAccessHandlerTests - { - private readonly Mock<IConfigurationManager> _configurationManagerMock; - private readonly List<IAuthorizationRequirement> _requirements; - private readonly LocalAccessHandler _sut; - private readonly Mock<IUserManager> _userManagerMock; - private readonly Mock<IHttpContextAccessor> _httpContextAccessor; - private readonly Mock<INetworkManager> _networkManagerMock; - - public LocalAccessHandlerTests() - { - var fixture = new Fixture().Customize(new AutoMoqCustomization()); - _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); - _requirements = new List<IAuthorizationRequirement> { new LocalAccessRequirement() }; - _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); - _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); - _networkManagerMock = fixture.Freeze<Mock<INetworkManager>>(); - - _sut = fixture.Create<LocalAccessHandler>(); - } - - [Theory] - [InlineData(true, true)] - [InlineData(false, false)] - public async Task LocalAccessOnly(bool isInLocalNetwork, bool shouldSucceed) - { - _networkManagerMock - .Setup(n => n.IsInLocalNetwork(It.IsAny<IPAddress>())) - .Returns(isInLocalNetwork); - - TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); - var claims = TestHelpers.SetupUser( - _userManagerMock, - _httpContextAccessor, - UserRoles.User); - - var context = new AuthorizationHandlerContext(_requirements, claims, null); - await _sut.HandleAsync(context); - Assert.Equal(shouldSucceed, context.HasSucceeded); - } - } -} diff --git a/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs deleted file mode 100644 index ffe88fcdeb..0000000000 --- a/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using AutoFixture; -using AutoFixture.AutoMoq; -using Jellyfin.Api.Auth.RequiresElevationPolicy; -using Jellyfin.Api.Constants; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Moq; -using Xunit; - -namespace Jellyfin.Api.Tests.Auth.RequiresElevationPolicy -{ - public class RequiresElevationHandlerTests - { - private readonly Mock<IConfigurationManager> _configurationManagerMock; - private readonly List<IAuthorizationRequirement> _requirements; - private readonly RequiresElevationHandler _sut; - private readonly Mock<IUserManager> _userManagerMock; - private readonly Mock<IHttpContextAccessor> _httpContextAccessor; - - public RequiresElevationHandlerTests() - { - var fixture = new Fixture().Customize(new AutoMoqCustomization()); - _configurationManagerMock = fixture.Freeze<Mock<IConfigurationManager>>(); - _requirements = new List<IAuthorizationRequirement> { new RequiresElevationRequirement() }; - _userManagerMock = fixture.Freeze<Mock<IUserManager>>(); - _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); - - _sut = fixture.Create<RequiresElevationHandler>(); - } - - [Theory] - [InlineData(UserRoles.Administrator, true)] - [InlineData(UserRoles.User, false)] - [InlineData(UserRoles.Guest, false)] - public async Task ShouldHandleRolesCorrectly(string role, bool shouldSucceed) - { - TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); - var claims = TestHelpers.SetupUser( - _userManagerMock, - _httpContextAccessor, - role); - - var context = new AuthorizationHandlerContext(_requirements, claims, null); - - await _sut.HandleAsync(context); - Assert.Equal(shouldSucceed, context.HasSucceeded); - } - } -} diff --git a/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs new file mode 100644 index 0000000000..0254a1ec6e --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs @@ -0,0 +1,35 @@ +using Jellyfin.Api.Controllers; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public static class ImageControllerTests +{ + [Theory] + [InlineData("image/apng", ".apng")] + [InlineData("image/avif", ".avif")] + [InlineData("image/bmp", ".bmp")] + [InlineData("image/gif", ".gif")] + [InlineData("image/x-icon", ".ico")] + [InlineData("image/jpeg", ".jpg")] + [InlineData("image/png", ".png")] + [InlineData("image/png; charset=utf-8", ".png")] + [InlineData("image/svg+xml", ".svg")] + [InlineData("image/tiff", ".tiff")] + [InlineData("image/webp", ".webp")] + public static void TryGetImageExtensionFromContentType_Valid_True(string contentType, string extension) + { + Assert.True(ImageController.TryGetImageExtensionFromContentType(contentType, out var ex)); + Assert.Equal(extension, ex); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("text/html")] + public static void TryGetImageExtensionFromContentType_InValid_False(string contentType) + { + Assert.False(ImageController.TryGetImageExtensionFromContentType(contentType, out var ex)); + Assert.Null(ex); + } +} diff --git a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs index c4640bd226..2d7741d81a 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Security.Claims; +using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Net; using Xunit; namespace Jellyfin.Api.Tests.Helpers @@ -15,6 +19,82 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(expected, RequestHelpers.GetOrderBy(sortBy, requestedSortOrder)); } + [Fact] + public static void GetUserId_IsAdmin() + { + Guid? requestUserId = Guid.NewGuid(); + Guid? authUserId = Guid.NewGuid(); + + var claims = new[] + { + new Claim(InternalClaimTypes.UserId, authUserId.Value.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.IsApiKey, bool.FalseString), + new Claim(ClaimTypes.Role, UserRoles.Administrator) + }; + + var identity = new ClaimsIdentity(claims, string.Empty); + var principal = new ClaimsPrincipal(identity); + + var userId = RequestHelpers.GetUserId(principal, requestUserId); + + Assert.Equal(requestUserId, userId); + } + + [Fact] + public static void GetUserId_IsApiKey_EmptyGuid() + { + Guid? requestUserId = Guid.Empty; + + var claims = new[] + { + new Claim(InternalClaimTypes.IsApiKey, bool.TrueString) + }; + + var identity = new ClaimsIdentity(claims, string.Empty); + var principal = new ClaimsPrincipal(identity); + + var userId = RequestHelpers.GetUserId(principal, requestUserId); + + Assert.Equal(Guid.Empty, userId); + } + + [Fact] + public static void GetUserId_IsApiKey_Null() + { + Guid? requestUserId = null; + + var claims = new[] + { + new Claim(InternalClaimTypes.IsApiKey, bool.TrueString) + }; + + var identity = new ClaimsIdentity(claims, string.Empty); + var principal = new ClaimsPrincipal(identity); + + var userId = RequestHelpers.GetUserId(principal, requestUserId); + + Assert.Equal(Guid.Empty, userId); + } + + [Fact] + public static void GetUserId_IsUser() + { + Guid? requestUserId = Guid.NewGuid(); + Guid? authUserId = Guid.NewGuid(); + + var claims = new[] + { + new Claim(InternalClaimTypes.UserId, authUserId.Value.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.IsApiKey, bool.FalseString), + new Claim(ClaimTypes.Role, UserRoles.User) + }; + + var identity = new ClaimsIdentity(claims, string.Empty); + var principal = new ClaimsPrincipal(identity); + + Assert.Throws<SecurityException>(() => RequestHelpers.GetUserId(principal, requestUserId)); + } + public static TheoryData<IReadOnlyList<string>, IReadOnlyList<SortOrder>, (string, SortOrder)[]> GetOrderBy_Success_TestData() { var data = new TheoryData<IReadOnlyList<string>, IReadOnlyList<SortOrder>, (string, SortOrder)[]>(); diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 6966d81d46..0150189108 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -5,37 +5,20 @@ <ProjectGuid>{A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D}</ProjectGuid> </PropertyGroup> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.17.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.2" /> - <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="AutoFixture" /> + <PackageReference Include="AutoFixture.AutoMoq" /> + <PackageReference Include="AutoFixture.Xunit2" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> + <PackageReference Include="Microsoft.Extensions.Options" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - <PackageReference Include="Moq" Version="4.18.4" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="Moq" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index 5110d59176..8fef7fde05 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -5,32 +5,15 @@ <ProjectGuid>{DF194677-DFD3-42AF-9F75-D44D5A416478}</ProjectGuid> </PropertyGroup> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - <PackageReference Include="FsCheck.Xunit" Version="2.16.5" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="FsCheck.Xunit" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 97350fedad..54d93b48cf 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -5,32 +5,15 @@ <ProjectGuid>{462584F7-5023-4019-9EAC-B98CA458C0A0}</ProjectGuid> </PropertyGroup> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="coverlet.collector" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj index a2ecd60838..69677ce424 100644 --- a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -1,31 +1,14 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="coverlet.collector" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.Dlna.Tests/Server/DescriptionXmlBuilderTests.cs b/tests/Jellyfin.Dlna.Tests/Server/DescriptionXmlBuilderTests.cs new file mode 100644 index 0000000000..c9018fe2f4 --- /dev/null +++ b/tests/Jellyfin.Dlna.Tests/Server/DescriptionXmlBuilderTests.cs @@ -0,0 +1,47 @@ +using Emby.Dlna.Server; +using MediaBrowser.Model.Dlna; +using Xunit; + +namespace Jellyfin.Dlna.Server.Tests; + +public class DescriptionXmlBuilderTests +{ + [Fact] + public void GetFriendlyName_EmptyProfile_ReturnsServerName() + { + const string ServerName = "Test Server Name"; + var builder = new DescriptionXmlBuilder(new DeviceProfile(), "serverUdn", "localhost", ServerName, string.Empty); + Assert.Equal(ServerName, builder.GetFriendlyName()); + } + + [Fact] + public void GetFriendlyName_FriendlyName_ReturnsFriendlyName() + { + const string FriendlyName = "Friendly Neighborhood Test Server"; + var builder = new DescriptionXmlBuilder( + new DeviceProfile() + { + FriendlyName = FriendlyName + }, + "serverUdn", + "localhost", + "Test Server Name", + string.Empty); + Assert.Equal(FriendlyName, builder.GetFriendlyName()); + } + + [Fact] + public void GetFriendlyName_FriendlyNameInterpolation_ReturnsFriendlyName() + { + var builder = new DescriptionXmlBuilder( + new DeviceProfile() + { + FriendlyName = "Friendly Neighborhood ${HostName}" + }, + "serverUdn", + "localhost", + "Test Server Name", + string.Empty); + Assert.Equal("Friendly Neighborhood TestServerName", builder.GetFriendlyName()); + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj index 313192b241..0364898298 100644 --- a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj +++ b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj @@ -1,34 +1,17 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0"> + <PackageReference Include="coverlet.collector"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="FsCheck.Xunit" Version="2.16.5" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="FsCheck.Xunit" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolStringTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolStringTests.cs new file mode 100644 index 0000000000..be256da2ea --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonBoolStringTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Jellyfin.Extensions.Json.Converters; +using Xunit; + +namespace Jellyfin.Extensions.Tests.Json.Converters +{ + public class JsonBoolStringTests + { + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions() + { + Converters = + { + new JsonBoolStringConverter() + } + }; + + [Theory] + [InlineData(@"{ ""Value"": ""true"" }", true)] + [InlineData(@"{ ""Value"": ""false"" }", false)] + public void Deserialize_String_Valid_Success(string input, bool output) + { + var s = JsonSerializer.Deserialize<TestStruct>(input, _jsonOptions); + Assert.Equal(s.Value, output); + } + + [Theory] + [InlineData(true, "true")] + [InlineData(false, "false")] + public void Serialize_Bool_Success(bool input, string output) + { + var value = JsonSerializer.Serialize(input, _jsonOptions); + Assert.Equal(value, output); + } + + private readonly record struct TestStruct(bool Value); + } +} diff --git a/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj b/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj index 22b0c417b0..eab003715c 100644 --- a/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj @@ -1,34 +1,18 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0"> + <PackageReference Include="coverlet.collector"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> </ItemGroup> - <!-- Code Analyzers --> - <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" /> <ProjectReference Include="..\..\src\Jellyfin.MediaEncoding.Keyframes\Jellyfin.MediaEncoding.Keyframes.csproj" /> diff --git a/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj b/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj index 373a54504e..894bec6aa5 100644 --- a/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj @@ -1,36 +1,18 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - <RootNamespace>Jellyfin.MediaEncoding.Keyframes</RootNamespace> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0"> + <PackageReference Include="coverlet.collector"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> </ItemGroup> - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - <ItemGroup> <ProjectReference Include="../../src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj" /> </ItemGroup> diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs index 1b27e344ba..db7e91c6a2 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs @@ -17,6 +17,8 @@ namespace Jellyfin.MediaEncoding.Tests } [Theory] + [InlineData(EncoderValidatorTestsData.FFmpegV60Output, true)] + [InlineData(EncoderValidatorTestsData.FFmpegV512Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV432Output, true)] [InlineData(EncoderValidatorTestsData.FFmpegV431Output, true)] @@ -36,6 +38,8 @@ namespace Jellyfin.MediaEncoding.Tests { public GetFFmpegVersionTestData() { + Add(EncoderValidatorTestsData.FFmpegV60Output, new Version(6, 0)); + Add(EncoderValidatorTestsData.FFmpegV512Output, new Version(5, 1, 2)); Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4)); Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2)); Add(EncoderValidatorTestsData.FFmpegV431Output, new Version(4, 3, 1)); diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs index 02bf046ed1..89ba42da0c 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs @@ -2,6 +2,30 @@ namespace Jellyfin.MediaEncoding.Tests { internal static class EncoderValidatorTestsData { + public const string FFmpegV60Output = @"ffmpeg version 6.0-Jellyfin Copyright (c) 2000-2023 the FFmpeg developers +built with gcc 12.2.0 (crosstool-NG 1.25.0.90_cf9beb1) +configuration: --prefix=/ffbuild/prefix --pkg-config=pkg-config --pkg-config-flags=--static --cross-prefix=x86_64-w64-mingw32- --arch=x86_64 --target-os=mingw32 --extra-version=Jellyfin --extra-cflags= --extra-cxxflags= --extra-ldflags= --extra-ldexeflags= --extra-libs= --enable-gpl --enable-version3 --enable-lto --disable-ffplay --disable-debug --disable-doc --disable-ptx-compression --disable-sdl2 --disable-w32threads --enable-pthreads --enable-iconv --enable-libxml2 --enable-zlib --enable-libfreetype --enable-libfribidi --enable-gmp --enable-lzma --enable-fontconfig --enable-libvorbis --enable-opencl --enable-amf --enable-chromaprint --enable-libdav1d --enable-dxva2 --enable-d3d11va --enable-libfdk-aac --enable-ffnvcodec --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvdec --enable-nvenc --enable-libass --enable-libbluray --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvpx --enable-libwebp --enable-libvpl --enable-schannel --enable-libsrt --enable-libsvtav1 --enable-vulkan --enable-libshaderc --enable-libplacebo --enable-libx264 --enable-libx265 --enable-libzimg --enable-libzvbi +libavutil 58. 2.100 / 58. 2.100 +libavcodec 60. 3.100 / 60. 3.100 +libavformat 60. 3.100 / 60. 3.100 +libavdevice 60. 1.100 / 60. 1.100 +libavfilter 9. 3.100 / 9. 3.100 +libswscale 7. 1.100 / 7. 1.100 +libswresample 4. 10.100 / 4. 10.100 +libpostproc 57. 1.100 / 57. 1.100"; + + public const string FFmpegV512Output = @"ffmpeg version 5.1.2-Jellyfin Copyright (c) 2000-2022 the FFmpeg developers +built with gcc 10-win32 (GCC) 20220324 +configuration: --prefix=/opt/ffmpeg --arch=x86_64 --target-os=mingw32 --cross-prefix=x86_64-w64-mingw32- --pkg-config=pkg-config --pkg-config-flags=--static --extra-libs='-lfftw3f -lstdc++' --extra-cflags=-DCHROMAPRINT_NODLL --extra-version=Jellyfin --disable-ffplay --disable-debug --disable-doc --disable-sdl2 --disable-ptx-compression --disable-w32threads --enable-pthreads --enable-shared --enable-lto --enable-gpl --enable-version3 --enable-schannel --enable-iconv --enable-libxml2 --enable-zlib --enable-lzma --enable-gmp --enable-chromaprint --enable-libfreetype --enable-libfribidi --enable-libfontconfig --enable-libass --enable-libbluray --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libzimg --enable-libx264 --enable-libx265 --enable-libsvtav1 --enable-libdav1d --enable-libfdk-aac --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-ffnvcodec --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvdec --enable-nvenc +libavutil 57. 28.100 / 57. 28.100 +libavcodec 59. 37.100 / 59. 37.100 +libavformat 59. 27.100 / 59. 27.100 +libavdevice 59. 7.100 / 59. 7.100 +libavfilter 8. 44.100 / 8. 44.100 +libswscale 6. 7.100 / 6. 7.100 +libswresample 4. 7.100 / 4. 7.100 +libpostproc 56. 6.100 / 56. 6.100"; + public const string FFmpegV44Output = @"ffmpeg version 4.4-Jellyfin Copyright (c) 2000-2021 the FFmpeg developers built with gcc 10.3.0 (Rev5, Built by MSYS2 project) configuration: --disable-static --enable-shared --extra-version=Jellyfin --disable-ffplay --disable-debug --enable-gpl --enable-version3 --enable-bzlib --enable-iconv --enable-lzma --enable-zlib --enable-sdl2 --enable-fontconfig --enable-gmp --enable-libass --enable-libzimg --enable-libbluray --enable-libfreetype --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libx264 --enable-libx265 --enable-libdav1d --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvenc --enable-nvdec --enable-ffnvcodec --enable-gnutls diff --git a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs deleted file mode 100644 index 97dbb3be03..0000000000 --- a/tests/Jellyfin.MediaEncoding.Tests/FFprobeParserTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.IO; -using System.Text.Json; -using System.Threading.Tasks; -using Jellyfin.Extensions.Json; -using MediaBrowser.MediaEncoding.Probing; -using MediaBrowser.Model.IO; -using Xunit; - -namespace Jellyfin.MediaEncoding.Tests -{ - public class FFprobeParserTests - { - [Theory] - [InlineData("ffprobe1.json")] - public async Task Test(string fileName) - { - var path = Path.Join("Test Data", fileName); - await using (var stream = AsyncFile.OpenRead(path)) - { - var res = await JsonSerializer.DeserializeAsync<InternalMediaInfoResult>(stream, JsonDefaults.Options).ConfigureAwait(false); - Assert.NotNull(res); - } - } - } -} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index a9a0dbc226..6b703e7416 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -5,12 +5,6 @@ <ProjectGuid>{28464062-0939-4AA7-9F7B-24DDDA61A7C0}</ProjectGuid> </PropertyGroup> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <None Include="Test Data\**\*.*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> @@ -18,30 +12,19 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.17.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="AutoFixture" /> + <PackageReference Include="AutoFixture.AutoMoq" /> + <PackageReference Include="AutoFixture.Xunit2" /> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - <ItemGroup> <ProjectReference Include="../../MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj" /> </ItemGroup> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index a64604e99f..198dc63efe 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -2,7 +2,9 @@ using System; using System.Globalization; using System.IO; using System.Text.Json; +using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.MediaEncoding.Probing; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -15,9 +17,15 @@ namespace Jellyfin.MediaEncoding.Tests.Probing { public class ProbeResultNormalizerTests { - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private readonly JsonSerializerOptions _jsonOptions; private readonly ProbeResultNormalizer _probeResultNormalizer = new ProbeResultNormalizer(new NullLogger<EncoderValidatorTests>(), null); + public ProbeResultNormalizerTests() + { + _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); + _jsonOptions.Converters.Add(new JsonBoolStringConverter()); + } + [Theory] [InlineData("2997/125", 23.976f)] [InlineData("1/50", 0.02f)] @@ -149,6 +157,19 @@ namespace Jellyfin.MediaEncoding.Tests.Probing } [Fact] + public void GetMediaInfo_TS_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_ts.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.False(res.MediaStreams[0].IsAVC); + } + + [Fact] public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success() { var bytes = File.ReadAllBytes("Test Data/Probing/video_progressive_no_field_order.json"); @@ -294,15 +315,15 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal(DateTime.Parse("2020-10-26T00:00Z", DateTimeFormatInfo.CurrentInfo, DateTimeStyles.AdjustToUniversal), res.PremiereDate); Assert.Equal(22, res.People.Length); Assert.Equal("Krysta Youngs", res.People[0].Name); - Assert.Equal(PersonType.Composer, res.People[0].Type); + Assert.Equal(PersonKind.Composer, res.People[0].Type); Assert.Equal("Julia Ross", res.People[1].Name); - Assert.Equal(PersonType.Composer, res.People[1].Type); + Assert.Equal(PersonKind.Composer, res.People[1].Type); Assert.Equal("Yiwoomin", res.People[2].Name); - Assert.Equal(PersonType.Composer, res.People[2].Type); + Assert.Equal(PersonKind.Composer, res.People[2].Type); Assert.Equal("Ji-hyo Park", res.People[3].Name); - Assert.Equal(PersonType.Lyricist, res.People[3].Type); + Assert.Equal(PersonKind.Lyricist, res.People[3].Type); Assert.Equal("Yiwoomin", res.People[4].Name); - Assert.Equal(PersonType.Actor, res.People[4].Type); + Assert.Equal(PersonKind.Actor, res.People[4].Type); Assert.Equal("Electric Piano", res.People[4].Role); Assert.Equal(4, res.Genres.Length); Assert.Contains("Electronic", res.Genres); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/ffprobe1.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts.json index cdad5df502..cdad5df502 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Test Data/ffprobe1.json +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts.json diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index f05a0152e0..c30dad6f90 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -162,7 +162,7 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen4-4K-5.1", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] - public async Task BuildVideoItemSimple(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = (TranscodeReason)0, string transcodeMode = "DirectStream", string transcodeProtocol = "") + public async Task BuildVideoItemSimple(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = default, string transcodeMode = "DirectStream", string transcodeProtocol = "") { var options = await GetMediaOptions(deviceName, mediaSource); BuildVideoItemSimpleTest(options, playMethod, why, transcodeMode, transcodeProtocol); @@ -260,7 +260,7 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen4-4K-5.1", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] - public async Task BuildVideoItemWithFirstExplicitStream(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = (TranscodeReason)0, string transcodeMode = "DirectStream", string transcodeProtocol = "") + public async Task BuildVideoItemWithFirstExplicitStream(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = default, string transcodeMode = "DirectStream", string transcodeProtocol = "") { var options = await GetMediaOptions(deviceName, mediaSource); options.AudioStreamIndex = 1; @@ -296,7 +296,7 @@ namespace Jellyfin.Model.Tests // Tizen 4 4K 5.1 [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] - public async Task BuildVideoItemWithDirectPlayExplicitStreams(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = (TranscodeReason)0, string transcodeMode = "DirectStream", string transcodeProtocol = "") + public async Task BuildVideoItemWithDirectPlayExplicitStreams(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = default, string transcodeMode = "DirectStream", string transcodeProtocol = "") { var options = await GetMediaOptions(deviceName, mediaSource); var streamCount = options.MediaSources[0].MediaStreams.Count; diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index 9858623f82..8345b610e5 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -1,21 +1,15 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - <PackageReference Include="FsCheck.Xunit" Version="2.16.5" /> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="FsCheck.Xunit" /> </ItemGroup> <ItemGroup> @@ -24,17 +18,6 @@ </None> </ItemGroup> - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - <ItemGroup> <ProjectReference Include="../../MediaBrowser.Model/MediaBrowser.Model.csproj" /> </ItemGroup> diff --git a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs index cbab455f01..371c3811ab 100644 --- a/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs +++ b/tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs @@ -127,9 +127,10 @@ namespace Jellyfin.Model.Tests.Net [InlineData("image/jpeg", ".jpg")] [InlineData("image/png", ".png")] [InlineData("image/svg+xml", ".svg")] - [InlineData("image/tiff", ".tif")] + [InlineData("image/tiff", ".tiff")] [InlineData("image/vnd.microsoft.icon", ".ico")] [InlineData("image/webp", ".webp")] + [InlineData("image/x-icon", ".ico")] [InlineData("image/x-png", ".png")] [InlineData("text/css", ".css")] [InlineData("text/csv", ".csv")] diff --git a/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs index 58aaed023a..c496632482 100644 --- a/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs +++ b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs @@ -12,8 +12,6 @@ namespace Jellyfin.Naming.Tests.Common Assert.NotEmpty(options.CleanDateTimeRegexes); Assert.NotEmpty(options.CleanStringRegexes); - Assert.NotEmpty(options.EpisodeWithoutSeasonRegexes); - Assert.NotEmpty(options.EpisodeMultiPartRegexes); } [Fact] diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 920f490ed0..112dd780e3 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -5,36 +5,19 @@ <ProjectGuid>{3998657B-1CCC-49DD-A19F-275DC8495F57}</ProjectGuid> </PropertyGroup> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> + <PackageReference Include="coverlet.collector" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\Emby.Naming\Emby.Naming.csproj" /> </ItemGroup> - <!-- Code Analyzers--> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - </Project> diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs index 68059f9806..406381f142 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs @@ -73,6 +73,11 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("[BBT-RMX] Ranma ½ - 154 [50AC421A].mkv", 154)] // hyphens in the pre-name info, triple digit episode number [InlineData("Season 2/Episode 21 - 94 Meetings.mp4", 21)] // Title starts with a number [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", 7)] + [InlineData("Season 3/The Series Season 3 Episode 9 - The title.avi", 9)] + [InlineData("Season 3/The Series S3 E9 - The title.avi", 9)] + [InlineData("Season 3/S003 E009.avi", 9)] + [InlineData("Season 3/Season 3 Episode 9.avi", 9)] + // [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number // TODO: [InlineData("Season 2/16 12 Some Title.avi", 16)] // TODO: [InlineData("Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", 3)] diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs index af219b1186..7604ddc803 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs @@ -30,6 +30,7 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/Season 02/Elementary - 02x03-E15 - Ep Name.mp4", false, "Elementary", 2, 3)] [InlineData("/Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", false, "Elementary", 1, 23)] [InlineData("/The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH/The Wonder Years s04e07 Christmas Party NTSC PDTV.avi", false, "The Wonder Years", 4, 7)] + [InlineData("/The.Sopranos/Season 3/The Sopranos Season 3 Episode 09 - The Telltale Moozadell.avi", false, "The Sopranos", 3, 9)] // TODO: [InlineData("/Castle Rock 2x01 Que el rio siga su curso [WEB-DL HULU 1080p h264 Dual DD5.1 Subs].mkv", "Castle Rock", 2, 1)] // TODO: [InlineData("/After Life 1x06 Episodio 6 [WEB-DL NF 1080p h264 Dual DD 5.1 Sub].mkv", "After Life", 1, 6)] // TODO: [InlineData("/Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", "Uchuu Senkan Yamoto 2199", 4, 3)] diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 287d881a83..294f11ee74 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -188,8 +188,7 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man-bluray.mkv", @"/movies/Iron Man/Iron Man-3d.mkv", @"/movies/Iron Man/Iron Man-3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man-3d.hsbs.mkv", - @"/movies/Iron Man/Iron Man[test].mkv", + @"/movies/Iron Man/Iron Man[test].mkv" }; var result = VideoListResolver.Resolve( @@ -197,10 +196,14 @@ namespace Jellyfin.Naming.Tests.Video _namingOptions).ToList(); Assert.Single(result); - Assert.Equal(7, result[0].AlternateVersions.Count); - Assert.False(result[0].AlternateVersions[2].Is3D); - Assert.True(result[0].AlternateVersions[3].Is3D); - Assert.True(result[0].AlternateVersions[4].Is3D); + 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); } [Fact] @@ -214,7 +217,6 @@ namespace Jellyfin.Naming.Tests.Video @"/movies/Iron Man/Iron Man - bluray.mkv", @"/movies/Iron Man/Iron Man - 3d.mkv", @"/movies/Iron Man/Iron Man - 3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man - 3d.hsbs.mkv", @"/movies/Iron Man/Iron Man [test].mkv" }; @@ -223,10 +225,14 @@ namespace Jellyfin.Naming.Tests.Video _namingOptions).ToList(); Assert.Single(result); - Assert.Equal(7, result[0].AlternateVersions.Count); - Assert.False(result[0].AlternateVersions[3].Is3D); - Assert.True(result[0].AlternateVersions[4].Is3D); - Assert.True(result[0].AlternateVersions[5].Is3D); + 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); } [Fact] @@ -324,6 +330,33 @@ namespace Jellyfin.Naming.Tests.Video } [Fact] + public void TestMultiVersion12() + { + var files = new[] + { + @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", + @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", + @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", + @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", + @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", + @"/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(); + + 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); + } + + [Fact] public void Resolve_GivenFolderNameWithBracketsAndHyphens_GroupsBasedOnFolderName() { var files = new[] diff --git a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs index 368c3592ef..97b52f7495 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs @@ -236,7 +236,7 @@ namespace Jellyfin.Naming.Tests.Video } [Fact] - public void TestFalsePositive() + public void TestMissingParttype() { var files = new[] { @@ -248,9 +248,8 @@ namespace Jellyfin.Naming.Tests.Video var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); - Assert.Single(result); - - TestStackInfo(result[0], "300", 3); + // There should be no stack, because all files should be treated as separate movies + Assert.Empty(result); } [Fact] @@ -297,11 +296,11 @@ namespace Jellyfin.Naming.Tests.Video var result = StackResolver.ResolveFiles(files, _namingOptions).ToList(); - Assert.Equal(3, result.Count); + // Only 'Bad Boys (2006)' and '300 (2006)' should be in the stack + Assert.Equal(2, result.Count); TestStackInfo(result[0], "300 (2006)", 4); - TestStackInfo(result[1], "300", 3); - TestStackInfo(result[2], "Bad Boys (2006)", 4); + TestStackInfo(result[1], "Bad Boys (2006)", 4); } [Fact] diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index cc9cfdd7dd..0316377d49 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -332,7 +332,9 @@ namespace Jellyfin.Naming.Tests.Video files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), _namingOptions).ToList(); - Assert.Single(result); + // 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' + Assert.Equal(2, result.Count); } [Fact] diff --git a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj index 74bf7cb0e0..4b4bdd2a51 100644 --- a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -5,33 +5,16 @@ <ProjectGuid>{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}</ProjectGuid> </PropertyGroup> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - <PackageReference Include="FsCheck.Xunit" Version="2.16.5" /> - <PackageReference Include="Moq" Version="4.18.4" /> - </ItemGroup> - - <!-- Code Analyzers--> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="FsCheck.Xunit" /> + <PackageReference Include="Moq" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs index 61f9132528..df2a2ca708 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs @@ -45,6 +45,7 @@ namespace Jellyfin.Networking.Tests [InlineData("fd23:184f:2029:0::/56", "fd24:184f:2029:0:3139:7386:67d7:d517")] [InlineData("fd23:184f:2029:0::/56, !fd23:184f:2029:0:3139:7386:67d7:d500/120", "fd23:184f:2029:0:3139:7386:67d7:d517")] [InlineData("fd23:184f:2029:0::/56", "192.168.10.60")] + [InlineData("2001:abcd:abcd:6b40::0/60", "192.168.10.60")] public void InNetwork_False_Success(string network, string value) { var ip = IPAddress.Parse(value); diff --git a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj index d3292c38eb..c12f0cd685 100644 --- a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -1,11 +1,5 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <None Include="Test Data\**\*.*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> @@ -13,30 +7,19 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0"> + <PackageReference Include="coverlet.collector"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> </ItemGroup> - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - <ItemGroup> <ProjectReference Include="../../MediaBrowser.Providers/MediaBrowser.Providers.csproj" /> </ItemGroup> diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index 08b343cd89..925e8fa199 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -94,7 +94,7 @@ namespace Jellyfin.Providers.Tests.Manager public void MergeImages_EmptyItemNewImagesEmpty_NoChange() { var itemImageProvider = GetItemImageProvider(null, null); - var changed = itemImageProvider.MergeImages(new Video(), Array.Empty<LocalImageInfo>()); + var changed = itemImageProvider.MergeImages(new Video(), Array.Empty<LocalImageInfo>(), new ImageRefreshOptions(Mock.Of<IDirectoryService>())); Assert.False(changed); } @@ -108,7 +108,7 @@ namespace Jellyfin.Providers.Tests.Manager var images = GetImages(imageType, imageCount, false); var itemImageProvider = GetItemImageProvider(null, null); - var changed = itemImageProvider.MergeImages(item, images); + var changed = itemImageProvider.MergeImages(item, images, new ImageRefreshOptions(Mock.Of<IDirectoryService>())); Assert.True(changed); // adds for types that allow multiple, replaces singular type images @@ -151,7 +151,7 @@ namespace Jellyfin.Providers.Tests.Manager var images = GetImages(imageType, imageCount, true); var itemImageProvider = GetItemImageProvider(null, fileSystem); - var changed = itemImageProvider.MergeImages(item, images); + var changed = itemImageProvider.MergeImages(item, images, new ImageRefreshOptions(Mock.Of<IDirectoryService>())); if (updateTime) { diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs index e18faa422d..ec4df9981c 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs @@ -238,9 +238,6 @@ namespace Jellyfin.Providers.Tests.Manager } }; - object? result; - List<PersonInfo> actual; - // overwrite provider id var overwriteNewValue = new List<PersonInfo> { @@ -249,9 +246,9 @@ namespace Jellyfin.Providers.Tests.Manager Name = "Name 2" } }; - Assert.False(TestMergeBaseItemDataPerson(GetOldValue(), overwriteNewValue, null, false, out result)); + Assert.False(TestMergeBaseItemDataPerson(GetOldValue(), overwriteNewValue, null, false, out var result)); // People not already in target are not merged into it from source - actual = (List<PersonInfo>)result!; + List<PersonInfo> actual = (List<PersonInfo>)result!; Assert.Single(actual); Assert.Equal("Name 1", actual[0].Name); diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 5ca59f0ede..400e30bd63 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -368,8 +368,8 @@ namespace Jellyfin.Providers.Tests.Manager [Theory] [InlineData(nameof(ICustomMetadataProvider), true)] [InlineData(nameof(IRemoteMetadataProvider), true)] - [InlineData(nameof(ILocalMetadataProvider), false)] - public void GetMetadataProviders_CanRefreshMetadataOwned_WhenNotLocal(string providerType, bool expected) + [InlineData(nameof(ILocalMetadataProvider), true)] + public void GetMetadataProviders_CanRefreshMetadataOwned(string providerType, bool expected) { GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs index 7d92e7b261..0d2b488bc7 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Data/SqliteItemRepositoryTests.cs @@ -6,6 +6,7 @@ using Emby.Server.Implementations.Data; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Configuration; using Moq; using Xunit; @@ -27,8 +28,18 @@ namespace Jellyfin.Server.Implementations.Tests.Data appHost.Setup(x => x.ReverseVirtualPath(It.IsAny<string>())) .Returns((string x) => x.Replace(MetaDataPath, VirtualMetaDataPath, StringComparison.Ordinal)); + var configSection = new Mock<IConfigurationSection>(); + configSection + .SetupGet(x => x[It.Is<string>(s => s == MediaBrowser.Controller.Extensions.ConfigurationExtensions.SqliteCacheSizeKey)]) + .Returns("0"); + var config = new Mock<IConfiguration>(); + config + .Setup(x => x.GetSection(It.Is<string>(s => s == MediaBrowser.Controller.Extensions.ConfigurationExtensions.SqliteCacheSizeKey))) + .Returns(configSection.Object); + _fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); _fixture.Inject(appHost); + _fixture.Inject(config); _sqliteItemRepository = _fixture.Create<SqliteItemRepository>(); } 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 b796e07d1a..9b6cb40b05 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -5,13 +5,6 @@ <ProjectGuid>{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}</ProjectGuid> </PropertyGroup> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - <RootNamespace>Jellyfin.Server.Implementations.Tests</RootNamespace> - </PropertyGroup> - <ItemGroup> <None Include="Test Data\**\*.*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> @@ -19,28 +12,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.17.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="AutoFixture" /> + <PackageReference Include="AutoFixture.AutoMoq" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Xunit.SkippableFact" Version="1.4.13" /> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="Xunit.SkippableFact" /> + <PackageReference Include="coverlet.collector" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/AudioResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/AudioResolverTests.cs new file mode 100644 index 0000000000..d136c1bc68 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/AudioResolverTests.cs @@ -0,0 +1,76 @@ +using System.Linq; +using Emby.Naming.Common; +using Emby.Server.Implementations.Library.Resolvers.Audio; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Library; + +public class AudioResolverTests +{ + private static readonly NamingOptions _namingOptions = new(); + + [Theory] + [InlineData("words.mp3")] // single non-tagged file + [InlineData("chapter 01.mp3")] + [InlineData("part 1.mp3")] + [InlineData("chapter 01.mp3", "non-media.txt")] + [InlineData("title.mp3", "title.epub")] + [InlineData("01.mp3", "subdirectory/")] // single media file with sub-directory - note that this will hide any contents in the subdirectory + public void Resolve_AudiobookDirectory_SingleResult(params string[] children) + { + var resolved = TestResolveChildren("/parent/title", children); + Assert.NotNull(resolved); + } + + [Theory] + /* Results that can't be displayed as an audio book. */ + [InlineData] // no contents + [InlineData("subdirectory/")] + [InlineData("non-media.txt")] + /* Names don't indicate parts of a single book. */ + [InlineData("Name.mp3", "Another Name.mp3")] + /* Results that are an audio book but not currently navigable as such (multiple chapters and/or parts). */ + [InlineData("01.mp3", "02.mp3")] + [InlineData("chapter 01.mp3", "chapter 02.mp3")] + [InlineData("part 1.mp3", "part 2.mp3")] + [InlineData("chapter 01 part 01.mp3", "chapter 01 part 02.mp3")] + /* Mismatched chapters, parts, and named files. */ + [InlineData("chapter 01.mp3", "part 2.mp3")] + [InlineData("book title.mp3", "chapter name.mp3")] // "book title" resolves as alternate version of book based on directory name + [InlineData("01 Content.mp3", "01 Credits.mp3")] // resolves as alternate versions of chapter 1 + [InlineData("Chapter Name.mp3", "Part 1.mp3")] + public void Resolve_AudiobookDirectory_NoResult(params string[] children) + { + var resolved = TestResolveChildren("/parent/book title", children); + Assert.Null(resolved); + } + + private Audio? TestResolveChildren(string parent, string[] children) + { + var childrenMetadata = children.Select(name => new FileSystemMetadata + { + FullName = parent + "/" + name, + IsDirectory = name.EndsWith('/') + }).ToArray(); + + var resolver = new AudioResolver(_namingOptions); + var itemResolveArgs = new ItemResolveArgs( + null, + Mock.Of<ILibraryManager>()) + { + CollectionType = "books", + FileInfo = new FileSystemMetadata + { + FullName = parent, + IsDirectory = true + }, + FileSystemChildren = childrenMetadata + }; + + return resolver.Resolve(itemResolveArgs); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs index 286ba04059..6d0ed7bbba 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs @@ -22,10 +22,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library { var parent = new Folder { Name = "extras" }; - var episodeResolver = new EpisodeResolver(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions); + var episodeResolver = new EpisodeResolver(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); var itemResolveArgs = new ItemResolveArgs( Mock.Of<IServerApplicationPaths>(), - Mock.Of<IDirectoryService>()) + null) { Parent = parent, CollectionType = CollectionType.TvShows, @@ -45,10 +45,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library // Have to create a mock because of moq proxies not being castable to a concrete implementation // https://github.com/jellyfin/jellyfin/blob/ab0cff8556403e123642dc9717ba778329554634/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs#L48 - var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions); + var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); var itemResolveArgs = new ItemResolveArgs( Mock.Of<IServerApplicationPaths>(), - Mock.Of<IDirectoryService>()) + null) { Parent = series, CollectionType = CollectionType.TvShows, @@ -62,7 +62,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library private sealed class EpisodeResolverMock : EpisodeResolver { - public EpisodeResolverMock(ILogger<EpisodeResolver> logger, NamingOptions namingOptions) : base(logger, namingOptions) + public EpisodeResolverMock(ILogger<EpisodeResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) : base(logger, namingOptions, directoryService) { } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index 5995990711..562711337f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -80,6 +80,35 @@ public class FindExtrasTests } [Fact] + public void FindExtras_SeparateMovieFolder_CleanExtraNames() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List<string> + { + "/movies/Up/Up.mkv", + "/movies/Up/Recording the audio[Bluray]-behindthescenes.mkv", + "/movies/Up/Interview with the dog-interview.mkv", + "/movies/Up/shorts/Balloons[1080p].mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList(); + + Assert.Equal(3, extras.Count); + Assert.Equal(ExtraType.BehindTheScenes, extras[0].ExtraType); + Assert.Equal("Recording the audio", extras[0].Name); + Assert.Equal(ExtraType.Interview, extras[1].ExtraType); + Assert.Equal("Interview with the dog", extras[1].Name); + Assert.Equal(ExtraType.Short, extras[2].ExtraType); + Assert.Equal("Balloons", extras[2].Name); + } + + [Fact] public void FindExtras_SeparateMovieFolderWithMixedExtras_FindsCorrectExtras() { var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs index efc3ac0c2a..aed584355c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs @@ -18,10 +18,10 @@ public class MovieResolverTests [Fact] public void Resolve_GivenLocalAlternateVersion_ResolvesToVideo() { - var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions); + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); var itemResolveArgs = new ItemResolveArgs( Mock.Of<IServerApplicationPaths>(), - Mock.Of<IDirectoryService>()) + null) { Parent = null, FileInfo = new FileSystemMetadata diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index be2dfe0a86..c33a957e69 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using Emby.Server.Implementations.Library; using Xunit; @@ -73,5 +74,47 @@ namespace Jellyfin.Server.Implementations.Tests.Library Assert.False(PathExtensions.TryReplaceSubPath(path, subPath, newSubPath, out var result)); Assert.Null(result); } + + [Theory] + [InlineData(null, '/', null)] + [InlineData(null, '\\', null)] + [InlineData("/home/jeff/myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] + [InlineData("C:\\Users\\Jeff\\myfile.mkv", '/', "C:/Users/Jeff/myfile.mkv")] + [InlineData("\\home/jeff\\myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] + [InlineData("\\home/jeff\\myfile.mkv", '/', "/home/jeff/myfile.mkv")] + [InlineData("", '/', "")] + public void NormalizePath_SpecifyingSeparator_Normalizes(string path, char separator, string expectedPath) + { + Assert.Equal(expectedPath, path.NormalizePath(separator)); + } + + [Theory] + [InlineData("/home/jeff/myfile.mkv")] + [InlineData("C:\\Users\\Jeff\\myfile.mkv")] + [InlineData("\\home/jeff\\myfile.mkv")] + public void NormalizePath_NoArgs_UsesDirectorySeparatorChar(string path) + { + var separator = Path.DirectorySeparatorChar; + + Assert.Equal(path.Replace('\\', separator).Replace('/', separator), path.NormalizePath()); + } + + [Theory] + [InlineData("/home/jeff/myfile.mkv", '/')] + [InlineData("C:\\Users\\Jeff\\myfile.mkv", '\\')] + [InlineData("\\home/jeff\\myfile.mkv", '/')] + public void NormalizePath_OutVar_Correct(string path, char expectedSeparator) + { + var result = path.NormalizePath(out var separator); + + Assert.Equal(expectedSeparator, separator); + Assert.Equal(path.Replace('\\', separator).Replace('/', separator), result); + } + + [Fact] + public void NormalizePath_SpecifyInvalidSeparator_ThrowsException() + { + Assert.Throws<ArgumentException>(() => string.Empty.NormalizePath('a')); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs index 82ce8fc4ec..92b4178fdb 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs @@ -67,4 +67,23 @@ public class XmlTvListingsProviderTests Assert.Equal("https://domain.tld/image.png", program.ImageUrl); Assert.Equal("3297", program.ChannelId); } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/emptycategory.xml")] + [InlineData("https://example.com/emptycategory.xml")] + public async Task GetProgramsAsync_EmptyCategories_Success(string path) + { + var info = new ListingsProviderInfo() + { + Path = path + }; + + var startDate = new DateTime(2022, 11, 4); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var programsList = programs.ToList(); + Assert.Single(programsList); + var program = programsList[0]; + Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); + Assert.Equal("3297", program.ChannelId); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 16eb7a75c6..7fabe99045 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -83,11 +83,11 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var ratings = localizationManager.GetParentalRatings().ToList(); - Assert.Equal(23, ratings.Count); + Assert.Equal(54, ratings.Count); var tvma = ratings.FirstOrDefault(x => x.Name.Equals("TV-MA", StringComparison.Ordinal)); Assert.NotNull(tvma); - Assert.Equal(9, tvma!.Value); + Assert.Equal(17, tvma!.Value); } [Fact] @@ -100,21 +100,21 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var ratings = localizationManager.GetParentalRatings().ToList(); - Assert.Equal(10, ratings.Count); + Assert.Equal(19, ratings.Count); var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); Assert.NotNull(fsk); - Assert.Equal(7, fsk!.Value); + Assert.Equal(12, fsk!.Value); } [Theory] - [InlineData("CA-R", "CA", 10)] - [InlineData("FSK-16", "DE", 8)] - [InlineData("FSK-18", "DE", 9)] - [InlineData("FSK-18", "US", 9)] - [InlineData("TV-MA", "US", 9)] - [InlineData("XXX", "asdf", 100)] - [InlineData("Germany: FSK-18", "DE", 9)] + [InlineData("CA-R", "CA", 18)] + [InlineData("FSK-16", "DE", 16)] + [InlineData("FSK-18", "DE", 18)] + [InlineData("FSK-18", "US", 18)] + [InlineData("TV-MA", "US", 17)] + [InlineData("XXX", "asdf", 1000)] + [InlineData("Germany: FSK-18", "DE", 18)] public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int expectedLevel) { var localizationManager = Setup(new ServerConfiguration() @@ -135,6 +135,9 @@ namespace Jellyfin.Server.Implementations.Tests.Localization UICulture = "de-DE" }); await localizationManager.LoadAll(); + Assert.Null(localizationManager.GetRatingLevel("NR")); + Assert.Null(localizationManager.GetRatingLevel("unrated")); + Assert.Null(localizationManager.GetRatingLevel("Not Rated")); Assert.Null(localizationManager.GetRatingLevel("n/a")); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index bc6a447410..d4b90dac02 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -1,7 +1,16 @@ using System; +using System.Globalization; using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using AutoFixture; +using Emby.Server.Implementations.Library; using Emby.Server.Implementations.Plugins; +using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Updates; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -11,6 +20,21 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins { private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); + private string _tempPath = string.Empty; + + private string _pluginPath = string.Empty; + + private JsonSerializerOptions _options; + + public PluginManagerTests() + { + (_tempPath, _pluginPath) = GetTestPaths("plugin-" + Path.GetRandomFileName()); + + Directory.CreateDirectory(_pluginPath); + + _options = GetTestSerializerOptions(); + } + [Fact] public void SaveManifest_RoundTrip_Success() { @@ -20,12 +44,9 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Version = "1.0" }; - var tempPath = Path.Combine(_testPathRoot, "manifest-" + Path.GetRandomFileName()); - Directory.CreateDirectory(tempPath); - - Assert.True(pluginManager.SaveManifest(manifest, tempPath)); + Assert.True(pluginManager.SaveManifest(manifest, _pluginPath)); - var res = pluginManager.LoadManifest(tempPath); + var res = pluginManager.LoadManifest(_pluginPath); Assert.Equal(manifest.Category, res.Manifest.Category); Assert.Equal(manifest.Changelog, res.Manifest.Changelog); @@ -40,6 +61,278 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins Assert.Equal(manifest.Status, res.Manifest.Status); Assert.Equal(manifest.AutoUpdate, res.Manifest.AutoUpdate); Assert.Equal(manifest.ImagePath, res.Manifest.ImagePath); + Assert.Equal(manifest.Assemblies, res.Manifest.Assemblies); + } + + /// <summary> + /// Tests safe traversal within the plugin directory. + /// </summary> + /// <param name="dllFile">The safe path to evaluate.</param> + [Theory] + [InlineData("./some.dll")] + [InlineData("some.dll")] + [InlineData("sub/path/some.dll")] + public void Constructor_DiscoversSafePluginAssembly_Status_Active(string dllFile) + { + var manifest = new PluginManifest + { + Id = Guid.NewGuid(), + Name = "Safe Assembly", + Assemblies = new string[] { dllFile } + }; + + var filename = Path.GetFileName(dllFile)!; + var dllPath = Path.GetDirectoryName(Path.Combine(_pluginPath, dllFile))!; + + Directory.CreateDirectory(dllPath); + File.Create(Path.Combine(dllPath, filename)); + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + + File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options)); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); + + var res = JsonSerializer.Deserialize<PluginManifest>(File.ReadAllText(metafilePath), _options); + + var expectedFullPath = Path.Combine(_pluginPath, dllFile).Canonicalize(); + + Assert.NotNull(res); + Assert.NotEmpty(pluginManager.Plugins); + Assert.Equal(PluginStatus.Active, res!.Status); + Assert.Equal(expectedFullPath, pluginManager.Plugins[0].DllFiles[0]); + Assert.StartsWith(_pluginPath, expectedFullPath, StringComparison.InvariantCulture); + } + + /// <summary> + /// Tests unsafe attempts to traverse to higher directories. + /// </summary> + /// <remarks> + /// Attempts to load directories outside of the plugin should be + /// constrained. Path traversal, shell expansion, and double encoding + /// can be used to load unintended files. + /// See <see href="https://owasp.org/www-community/attacks/Path_Traversal"/> for more. + /// </remarks> + /// <param name="unsafePath">The unsafe path to evaluate.</param> + [Theory] + [InlineData("/some.dll")] // Root path. + [InlineData("../some.dll")] // Simple traversal. + [InlineData("C:\\some.dll")] // Windows root path. + [InlineData("test.txt")] // Not a DLL + [InlineData(".././.././../some.dll")] // Traversal with current and parent + [InlineData("..\\.\\..\\.\\..\\some.dll")] // Windows traversal with current and parent + [InlineData("\\\\network\\resource.dll")] // UNC Path + [InlineData("https://jellyfin.org/some.dll")] // URL + [InlineData("~/some.dll")] // Tilde poses a shell expansion risk, but is a valid path character. + public void Constructor_DiscoversUnsafePluginAssembly_Status_Malfunctioned(string unsafePath) + { + var manifest = new PluginManifest + { + Id = Guid.NewGuid(), + Name = "Unsafe Assembly", + Assemblies = new string[] { unsafePath } + }; + + // Only create very specific files. Otherwise the test will be exploiting path traversal. + var files = new string[] + { + "../other.dll", + "some.dll" + }; + + foreach (var file in files) + { + File.Create(Path.Combine(_pluginPath, file)); + } + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + + File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options)); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); + + var res = JsonSerializer.Deserialize<PluginManifest>(File.ReadAllText(metafilePath), _options); + + Assert.NotNull(res); + Assert.Empty(pluginManager.Plugins); + Assert.Equal(PluginStatus.Malfunctioned, res!.Status); + } + + [Fact] + public async Task PopulateManifest_ExistingMetafilePlugin_PopulatesMissingFields() + { + var packageInfo = GenerateTestPackage(); + + // Partial plugin without a name, but matching version and package ID + var partial = new PluginManifest + { + Id = packageInfo.Id, + AutoUpdate = false, // Turn off AutoUpdate + Status = PluginStatus.Restart, + Version = new Version(1, 0, 0).ToString(), + Assemblies = new[] { "Jellyfin.Test.dll" } + }; + + var expectedManifest = new PluginManifest + { + Id = partial.Id, + Name = packageInfo.Name, + AutoUpdate = partial.AutoUpdate, + Status = PluginStatus.Active, + Owner = packageInfo.Owner, + Assemblies = partial.Assemblies, + Category = packageInfo.Category, + Description = packageInfo.Description, + Overview = packageInfo.Overview, + TargetAbi = packageInfo.Versions[0].TargetAbi!, + Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), + Changelog = packageInfo.Versions[0].Changelog!, + Version = new Version(1, 0).ToString(), + ImagePath = string.Empty + }; + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equivalent(expectedManifest, result); + } + + [Fact] + public async Task PopulateManifest_NoMetafile_PreservesManifest() + { + var packageInfo = GenerateTestPackage(); + var expectedManifest = new PluginManifest + { + Id = packageInfo.Id, + Name = packageInfo.Name, + AutoUpdate = true, + Status = PluginStatus.Active, + Owner = packageInfo.Owner, + Assemblies = Array.Empty<string>(), + Category = packageInfo.Category, + Description = packageInfo.Description, + Overview = packageInfo.Overview, + TargetAbi = packageInfo.Versions[0].TargetAbi!, + Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture), + Changelog = packageInfo.Versions[0].Changelog!, + Version = packageInfo.Versions[0].Version, + ImagePath = string.Empty + }; + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, null!, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equivalent(expectedManifest, result); + } + + [Fact] + public async Task PopulateManifest_ExistingMetafileMismatchedIds_Status_Malfunctioned() + { + var packageInfo = GenerateTestPackage(); + + // Partial plugin without a name, but matching version and package ID + var partial = new PluginManifest + { + Id = Guid.NewGuid(), + Version = new Version(1, 0, 0).ToString() + }; + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equal(packageInfo.Name, result.Name); + Assert.Equal(PluginStatus.Malfunctioned, result.Status); + } + + [Fact] + public async Task PopulateManifest_ExistingMetafileMismatchedVersions_Updates_Version() + { + var packageInfo = GenerateTestPackage(); + + var partial = new PluginManifest + { + Id = packageInfo.Id, + Version = new Version(2, 0, 0).ToString() + }; + + var metafilePath = Path.Combine(_pluginPath, "meta.json"); + File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + + var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); + + await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); + + var resultBytes = File.ReadAllBytes(metafilePath); + var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); + + Assert.NotNull(result); + Assert.Equal(packageInfo.Name, result.Name); + Assert.Equal(PluginStatus.Active, result.Status); + Assert.Equal(packageInfo.Versions[0].Version, result.Version); + } + + private PackageInfo GenerateTestPackage() + { + var fixture = new Fixture(); + fixture.Customize<PackageInfo>(c => c.Without(x => x.Versions).Without(x => x.ImageUrl)); + fixture.Customize<VersionInfo>(c => c.Without(x => x.Version).Without(x => x.Timestamp)); + + var versionInfo = fixture.Create<VersionInfo>(); + versionInfo.Version = new Version(1, 0).ToString(); + versionInfo.Timestamp = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture); + + var packageInfo = fixture.Create<PackageInfo>(); + packageInfo.Versions = new[] { versionInfo }; + + return packageInfo; + } + + private JsonSerializerOptions GetTestSerializerOptions() + { + var options = new JsonSerializerOptions(JsonDefaults.Options) + { + WriteIndented = true + }; + + for (var i = 0; i < options.Converters.Count; i++) + { + // Remove the Guid converter for parity with plugin manager. + if (options.Converters[i] is JsonGuidConverter converter) + { + options.Converters.Remove(converter); + } + } + + return options; + } + + private (string TempPath, string PluginPath) GetTestPaths(string pluginFolderName) + { + var tempPath = Path.Combine(_testPathRoot, "plugin-manager" + Path.GetRandomFileName()); + var pluginPath = Path.Combine(tempPath, pluginFolderName); + + return (tempPath, pluginPath); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml new file mode 100644 index 0000000000..dd4aa89774 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml @@ -0,0 +1,6 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 -0400" stop="20221105235959 -0400"> + <category lang="en" /> + <category lang="en">sports</category> + </programme> +</tv> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json index fa8fbd8d2c..57367ce88c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json @@ -681,4 +681,4 @@ } ] } -]
\ No newline at end of file +] diff --git a/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs index 9eb0beda44..3737fee0ac 100644 --- a/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs +++ b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Models.StartupDtos; using Jellyfin.Api.Models.UserDtos; using Jellyfin.Extensions.Json; +using MediaBrowser.Model.Dto; using Xunit; namespace Jellyfin.Server.Integration.Tests @@ -43,6 +44,33 @@ namespace Jellyfin.Server.Integration.Tests return auth!.AccessToken; } + public static async Task<UserDto> GetUserDtoAsync(HttpClient client) + { + using var response = await client.GetAsync("Users/Me").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var userDto = await JsonSerializer.DeserializeAsync<UserDto>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), JsonDefaults.Options).ConfigureAwait(false); + Assert.NotNull(userDto); + return userDto; + } + + public static async Task<BaseItemDto> GetRootFolderDtoAsync(HttpClient client, Guid userId = default) + { + if (userId.Equals(default)) + { + var userDto = await GetUserDtoAsync(client).ConfigureAwait(false); + userId = userDto.Id; + } + + var response = await client.GetAsync($"Users/{userId}/Items/Root").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rootDto = await JsonSerializer.DeserializeAsync<BaseItemDto>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + JsonDefaults.Options).ConfigureAwait(false); + Assert.NotNull(rootDto); + return rootDto; + } + public static void AddAuthHeader(this HttpHeaders headers, string accessToken) { headers.Add(AuthHeaderName, DummyAuthHeader + $", Token={accessToken}"); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs new file mode 100644 index 0000000000..0780029949 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Extensions.Json; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class ItemsControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private static string? _accessToken; + + public ItemsControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetItems_NoApiKeyOrUserId_Success() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("Items").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("Users/{0}/Items")] + [InlineData("Users/{0}/Items/Resume")] + public async Task GetUserItems_NonExistentUserId_NotFound(string format) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("Items?userId={0}")] + [InlineData("Users/{0}/Items")] + [InlineData("Users/{0}/Items/Resume")] + public async Task GetItems_UserId_Ok(string format) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id)).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var items = await JsonSerializer.DeserializeAsync<QueryResult<BaseItemDto>>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + _jsonOptions).ConfigureAwait(false); + Assert.NotNull(items); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs new file mode 100644 index 0000000000..8998683a79 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Globalization; +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public LibraryControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData("Items/{0}/File")] + [InlineData("Items/{0}/ThemeSongs")] + [InlineData("Items/{0}/ThemeVideos")] + [InlineData("Items/{0}/ThemeMedia")] + [InlineData("Items/{0}/Ancestors")] + [InlineData("Items/{0}/Download")] + [InlineData("Artists/{0}/Similar")] + [InlineData("Items/{0}/Similar")] + [InlineData("Albums/{0}/Similar")] + [InlineData("Shows/{0}/Similar")] + [InlineData("Movies/{0}/Similar")] + [InlineData("Trailers/{0}/Similar")] + public async Task Get_NonExistentItemId_NotFound(string format) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("Items/{0}")] + [InlineData("Items?ids={0}")] + public async Task Delete_NonExistentItemId_Unauthorised(string format) + { + var client = _factory.CreateClient(); + + var response = await client.DeleteAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("Items/{0}")] + [InlineData("Items?ids={0}")] + public async Task Delete_NonExistentItemId_NotFound(string format) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.DeleteAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/MusicGenreControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/MusicGenreControllerTests.cs new file mode 100644 index 0000000000..17f3dc99fd --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/MusicGenreControllerTests.cs @@ -0,0 +1,26 @@ +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class MusicGenreControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public MusicGenreControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task MusicGenres_FakeMusicGenre_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync("MusicGenres/Fake-MusicGenre").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/PlaystateControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/PlaystateControllerTests.cs new file mode 100644 index 0000000000..868ecd53f5 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/PlaystateControllerTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public class PlaystateControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public PlaystateControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task DeleteMarkUnplayedItem_NonExistentUserId_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var response = await client.DeleteAsync($"Users/{Guid.NewGuid()}/PlayedItems/{Guid.NewGuid()}").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMarkPlayedItem_NonExistentUserId_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var response = await client.PostAsync($"Users/{Guid.NewGuid()}/PlayedItems/{Guid.NewGuid()}", null).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DeleteMarkUnplayedItem_NonExistentItemId_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + + using var response = await client.DeleteAsync($"Users/{userDto.Id}/PlayedItems/{Guid.NewGuid()}").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMarkPlayedItem_NonExistentItemId_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + + using var response = await client.PostAsync($"Users/{userDto.Id}/PlayedItems/{Guid.NewGuid()}", null).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs new file mode 100644 index 0000000000..cb0a829e8e --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs @@ -0,0 +1,27 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public class SessionControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public SessionControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetSessions_NonExistentUserId_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + using var response = await client.GetAsync($"Session/Sessions?userId={Guid.NewGuid()}").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs index 2b825a93a0..2a3c53dbe4 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs @@ -67,6 +67,16 @@ namespace Jellyfin.Server.Integration.Tests.Controllers } [Fact] + [Priority(-1)] + public async Task Me_Valid_Success() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + _ = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + } + + [Fact] [Priority(0)] public async Task New_Valid_Success() { @@ -108,7 +118,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers var createRequest = new CreateUserByName() { - Name = username + Name = username! }; using var response = await CreateUserByName(client, createRequest).ConfigureAwait(false); @@ -116,6 +126,19 @@ namespace Jellyfin.Server.Integration.Tests.Controllers } [Fact] + [Priority(0)] + public async Task Delete_DoesntExist_NotFound() + { + var client = _factory.CreateClient(); + + // access token can't be null here as the previous test populated it + client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); + + using var response = await client.DeleteAsync($"User/{Guid.NewGuid()}").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] [Priority(1)] public async Task UpdateUserPassword_Valid_Success() { diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs new file mode 100644 index 0000000000..69f2ccf339 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Extensions.Json; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private static string? _accessToken; + + public UserLibraryControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetRootFolder_NonExistenUserId_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.GetAsync($"Users/{Guid.NewGuid()}/Items/Root").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetRootFolder_UserId_Valid() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + _ = await AuthHelper.GetRootFolderDtoAsync(client).ConfigureAwait(false); + } + + [Theory] + [InlineData("Users/{0}/Items/{1}")] + [InlineData("Users/{0}/Items/{1}/Intros")] + [InlineData("Users/{0}/Items/{1}/LocalTrailers")] + [InlineData("Users/{0}/Items/{1}/SpecialFeatures")] + [InlineData("Users/{0}/Items/{1}/Lyrics")] + public async Task GetItem_NonExistenUserId_NotFound(string format) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client).ConfigureAwait(false); + + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid(), rootFolderDto.Id)).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("Users/{0}/Items/{1}")] + [InlineData("Users/{0}/Items/{1}/Intros")] + [InlineData("Users/{0}/Items/{1}/LocalTrailers")] + [InlineData("Users/{0}/Items/{1}/SpecialFeatures")] + [InlineData("Users/{0}/Items/{1}/Lyrics")] + public async Task GetItem_NonExistentItemId_NotFound(string format) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id, Guid.NewGuid())).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetItem_UserIdAndItemId_Valid() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id).ConfigureAwait(false); + + var response = await client.GetAsync($"Users/{userDto.Id}/Items/{rootFolderDto.Id}").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rootDto = await JsonSerializer.DeserializeAsync<BaseItemDto>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + _jsonOptions).ConfigureAwait(false); + Assert.NotNull(rootDto); + } + + [Fact] + public async Task GetIntros_UserIdAndItemId_Valid() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id).ConfigureAwait(false); + + var response = await client.GetAsync($"Users/{userDto.Id}/Items/{rootFolderDto.Id}/Intros").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rootDto = await JsonSerializer.DeserializeAsync<QueryResult<BaseItemDto>>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + _jsonOptions).ConfigureAwait(false); + Assert.NotNull(rootDto); + } + + [Theory] + [InlineData("Users/{0}/Items/{1}/LocalTrailers")] + [InlineData("Users/{0}/Items/{1}/SpecialFeatures")] + public async Task LocalTrailersAndSpecialFeatures_UserIdAndItemId_Valid(string format) + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id).ConfigureAwait(false); + + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id, rootFolderDto.Id)).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rootDto = await JsonSerializer.DeserializeAsync<BaseItemDto[]>( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + _jsonOptions).ConfigureAwait(false); + Assert.NotNull(rootDto); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/VideosControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/VideosControllerTests.cs new file mode 100644 index 0000000000..0f9a2e90aa --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/VideosControllerTests.cs @@ -0,0 +1,27 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class VideosControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public VideosControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task DeleteAlternateSources_NonExistentItemId_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + + var response = await client.DeleteAsync($"Videos/{Guid.NewGuid()}").ConfigureAwait(false); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj index c40f6942b1..a5296d8c93 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj +++ b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj @@ -1,25 +1,20 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.17.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.2" /> - <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="AutoFixture" /> + <PackageReference Include="AutoFixture.AutoMoq" /> + <PackageReference Include="AutoFixture.Xunit2" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> + <PackageReference Include="Microsoft.Extensions.Options" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Xunit.Priority" Version="1.1.6" /> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - <PackageReference Include="Moq" Version="4.18.4" /> + <PackageReference Include="Xunit.Priority" /> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="Moq" /> </ItemGroup> <ItemGroup> @@ -29,17 +24,6 @@ </None> </ItemGroup> - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - <ItemGroup> <ProjectReference Include="../../Jellyfin.Server/Jellyfin.Server.csproj" /> </ItemGroup> diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj index a72a6f1855..5fea805ae1 100644 --- a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj +++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj @@ -1,36 +1,19 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <PackageReference Include="AutoFixture" Version="4.17.0" /> - <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.2" /> - <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="AutoFixture" /> + <PackageReference Include="AutoFixture.AutoMoq" /> + <PackageReference Include="AutoFixture.Xunit2" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> + <PackageReference Include="Microsoft.Extensions.Options" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - <PackageReference Include="Moq" Version="4.18.4" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="coverlet.collector" /> + <PackageReference Include="Moq" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj index dc5b5b9e6b..9fe0744de1 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj +++ b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj @@ -1,11 +1,5 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <IsPackable>false</IsPackable> - <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <None Include="Test Data\**\*.*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> @@ -13,25 +7,14 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> - <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="xunit" Version="2.4.2" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="Moq" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.2.0" /> - </ItemGroup> - - <!-- Code Analyzers --> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> + <PackageReference Include="coverlet.collector" /> </ItemGroup> <ItemGroup> diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs index 4f4ae5afb9..f63bc0e1bc 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -79,18 +80,18 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Equal("1276153", item.ProviderIds[MetadataProvider.Tmdb.ToString()]); // Credits - var writers = result.People.Where(x => x.Type == PersonType.Writer).ToArray(); + var writers = result.People.Where(x => x.Type == PersonKind.Writer).ToArray(); Assert.Equal(2, writers.Length); Assert.Contains("Bryan Fuller", writers.Select(x => x.Name)); Assert.Contains("Michael Green", writers.Select(x => x.Name)); // Direcotrs - var directors = result.People.Where(x => x.Type == PersonType.Director).ToArray(); + var directors = result.People.Where(x => x.Type == PersonKind.Director).ToArray(); Assert.Single(directors); Assert.Contains("David Slade", directors.Select(x => x.Name)); // Actors - var actors = result.People.Where(x => x.Type == PersonType.Actor).ToArray(); + var actors = result.People.Where(x => x.Type == PersonKind.Actor).ToArray(); Assert.Equal(11, actors.Length); // Only test one actor var shadow = actors.FirstOrDefault(x => x.Role.Equals("Shadow Moon", StringComparison.Ordinal)); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index 988abce812..f56f58c6fe 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using System.Threading; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -117,18 +118,18 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Equal(20, result.People.Count); - var writers = result.People.Where(x => x.Type == PersonType.Writer).ToArray(); + var writers = result.People.Where(x => x.Type == PersonKind.Writer).ToArray(); Assert.Equal(3, writers.Length); var writerNames = writers.Select(x => x.Name); Assert.Contains("Jerry Siegel", writerNames); Assert.Contains("Joe Shuster", writerNames); Assert.Contains("Test", writerNames); - var directors = result.People.Where(x => x.Type == PersonType.Director).ToArray(); + var directors = result.People.Where(x => x.Type == PersonKind.Director).ToArray(); Assert.Single(directors); Assert.Equal("Zack Snyder", directors[0].Name); - var actors = result.People.Where(x => x.Type == PersonType.Actor).ToArray(); + var actors = result.People.Where(x => x.Type == PersonKind.Actor).ToArray(); Assert.Equal(15, actors.Length); // Only test one actor @@ -138,7 +139,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Equal(5, aquaman!.SortOrder); Assert.Equal("https://m.media-amazon.com/images/M/MV5BMTI5MTU5NjM1MV5BMl5BanBnXkFtZTcwODc4MDk0Mw@@._V1_SX1024_SY1024_.jpg", aquaman!.ImageUrl); - var lyricist = result.People.FirstOrDefault(x => x.Type == PersonType.Lyricist); + var lyricist = result.People.FirstOrDefault(x => x.Type == PersonKind.Lyricist); Assert.NotNull(lyricist); Assert.Equal("Test Lyricist", lyricist!.Name); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeasonNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeasonNfoProviderTests.cs index 31110dbd7d..e69ca996cc 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeasonNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeasonNfoProviderTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -60,7 +61,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Equal(10, result.People.Count); - Assert.True(result.People.All(x => x.Type == PersonType.Actor)); + Assert.True(result.People.All(x => x.Type == PersonKind.Actor)); // Only test one actor var nini = result.People.FirstOrDefault(x => x.Role.Equals("Nini", StringComparison.Ordinal)); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeriesNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeriesNfoParserTests.cs index bdedae205b..f680d2dcc5 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeriesNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/SeriesNfoParserTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; @@ -67,7 +68,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.Equal(6, result.People.Count); - Assert.True(result.People.All(x => x.Type == PersonType.Actor)); + Assert.True(result.People.All(x => x.Type == PersonKind.Actor)); // Only test one actor var sweeney = result.People.FirstOrDefault(x => x.Role.Equals("Mad Sweeney", StringComparison.Ordinal)); @@ -89,7 +90,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers }; _parser.Fetch(result, path, CancellationToken.None); - var item = (Series)result.Item; + var item = result.Item; Assert.Equal(id, item.ProviderIds[provider]); } |
