diff options
597 files changed, 29316 insertions, 29995 deletions
diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 2dc7fb5a3..4b5571c77 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -27,7 +27,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 + uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -36,7 +36,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 + uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' continue-on-error: true with: @@ -45,7 +45,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 + uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -59,7 +59,7 @@ jobs: run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 + uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 continue-on-error: true with: @@ -68,7 +68,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 + uses: alex-page/github-project-automation-plus@7ffb872c64bd809d23563a130a0a97d01dfa8f43 # v0.8.3 if: github.event.issue.pull_request == '' && github.event.action == 'opened' continue-on-error: true with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 238e31f4b..cdb7970ac 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@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # v3.0.3 with: dotnet-version: '7.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@515828d97454b8354517688ddc5b48402b723750 # v2 + uses: github/codeql-action/init@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2.2.7 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@515828d97454b8354517688ddc5b48402b723750 # v2 + uses: github/codeql-action/autobuild@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2.2.7 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@515828d97454b8354517688ddc5b48402b723750 # v2 + uses: github/codeql-action/analyze@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2.2.7 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 5d945c001..fe1317a75 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@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 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@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 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@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 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@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 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@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 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@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 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@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 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 4577ff525..8fa8971ec 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@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # v3.0.3 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,7 +39,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3.4.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -51,13 +51,13 @@ jobs: ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/${{ github.head_ref }}) git checkout --progress --force $ANCESTOR_REF - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # v3.0.3 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 +76,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 +103,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@034abe94d3191f9c89d870519735beae326f2bdb # v2.3.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@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 if: ${{ steps.read-diff.outputs.body != '' }} with: issue-number: ${{ github.event.pull_request.number }} @@ -125,7 +125,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@67dcc547d311b736a8e6c5c236542148a47adc3d # v2.1.1 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 897b7014a..276bb2150 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -12,13 +12,14 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@6f05e4244c9a0b2ed3401882b05d701dd0a7289b # v7 + - uses: actions/stale@6f05e4244c9a0b2ed3401882b05d701dd0a7289b # v7.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} days-before-stale: 120 days-before-pr-stale: -1 days-before-close: 21 days-before-pr-close: -1 + operations-per-run: 75 exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed stale-issue-label: stale stale-issue-message: |- diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b7a317000..000000000 --- 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 ec3c6fd2a..c9430b235 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) @@ -162,6 +163,7 @@ - [vgambier](https://github.com/vgambier) - [MinecraftPlaye](https://github.com/MinecraftPlaye) - [RealGreenDragon](https://github.com/RealGreenDragon) + - [ipitio](https://github.com/ipitio) # Emby Contributors @@ -231,3 +233,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 000000000..ba02b21fd --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,90 @@ +<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="3.2.0" /> + <PackageVersion Include="Diacritics" Version="3.3.14" /> + <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> + <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> + <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.6" /> + <PackageVersion Include="FsCheck.Xunit" Version="2.16.5" /> + <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> + <PackageVersion Include="libse" Version="3.6.11" /> + <PackageVersion Include="LrcParser" Version="2023.308.0" /> + <PackageVersion Include="MetaBrainz.MusicBrainz" Version="5.0.0" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.4" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.4" /> + <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.4" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.4" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.4" /> + <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.4" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.4" /> + <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.0" /> + <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.5.0" /> + <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.3.1" /> + <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="6.1.0" /> + <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" /> + <PackageVersion Include="Serilog.Settings.Configuration" Version="3.4.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="2.3.0" /> + <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> + <PackageVersion Include="SharpFuzz" Version="2.0.1" /> + <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" /> + <PackageVersion Include="SkiaSharp.Svg" Version="1.60.0" /> + <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.4" /> + <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.435" /> + <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.2" /> + <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 304f79463..e51d285e1 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 bbb84a461..46a3e9b99 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 5572586ae..4f9d5e1fd 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/DvdLib/BigEndianBinaryReader.cs b/DvdLib/BigEndianBinaryReader.cs deleted file mode 100644 index b3aad85ce..000000000 --- a/DvdLib/BigEndianBinaryReader.cs +++ /dev/null @@ -1,25 +0,0 @@ -#pragma warning disable CS1591 - -using System.Buffers.Binary; -using System.IO; - -namespace DvdLib -{ - public class BigEndianBinaryReader : BinaryReader - { - public BigEndianBinaryReader(Stream input) - : base(input) - { - } - - public override ushort ReadUInt16() - { - return BinaryPrimitives.ReadUInt16BigEndian(base.ReadBytes(2)); - } - - public override uint ReadUInt32() - { - return BinaryPrimitives.ReadUInt32BigEndian(base.ReadBytes(4)); - } - } -} diff --git a/DvdLib/DvdLib.csproj b/DvdLib/DvdLib.csproj deleted file mode 100644 index 1053c0089..000000000 --- a/DvdLib/DvdLib.csproj +++ /dev/null @@ -1,20 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> - <PropertyGroup> - <ProjectGuid>{713F42B5-878E-499D-A878-E4C652B1D5E8}</ProjectGuid> - </PropertyGroup> - - <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> - </ItemGroup> - - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <GenerateDocumentationFile>true</GenerateDocumentationFile> - <AnalysisMode>AllDisabledByDefault</AnalysisMode> - <Nullable>disable</Nullable> - </PropertyGroup> - -</Project> diff --git a/DvdLib/Ifo/Cell.cs b/DvdLib/Ifo/Cell.cs deleted file mode 100644 index ea0b50e43..000000000 --- a/DvdLib/Ifo/Cell.cs +++ /dev/null @@ -1,23 +0,0 @@ -#pragma warning disable CS1591 - -using System.IO; - -namespace DvdLib.Ifo -{ - public class Cell - { - public CellPlaybackInfo PlaybackInfo { get; private set; } - - public CellPositionInfo PositionInfo { get; private set; } - - internal void ParsePlayback(BinaryReader br) - { - PlaybackInfo = new CellPlaybackInfo(br); - } - - internal void ParsePosition(BinaryReader br) - { - PositionInfo = new CellPositionInfo(br); - } - } -} diff --git a/DvdLib/Ifo/CellPlaybackInfo.cs b/DvdLib/Ifo/CellPlaybackInfo.cs deleted file mode 100644 index 6e33a0ec5..000000000 --- a/DvdLib/Ifo/CellPlaybackInfo.cs +++ /dev/null @@ -1,52 +0,0 @@ -#pragma warning disable CS1591 - -using System.IO; - -namespace DvdLib.Ifo -{ - public enum BlockMode - { - NotInBlock = 0, - FirstCell = 1, - InBlock = 2, - LastCell = 3, - } - - public enum BlockType - { - Normal = 0, - Angle = 1, - } - - public enum PlaybackMode - { - Normal = 0, - StillAfterEachVOBU = 1, - } - - public class CellPlaybackInfo - { - public readonly BlockMode Mode; - public readonly BlockType Type; - public readonly bool SeamlessPlay; - public readonly bool Interleaved; - public readonly bool STCDiscontinuity; - public readonly bool SeamlessAngle; - public readonly PlaybackMode PlaybackMode; - public readonly bool Restricted; - public readonly byte StillTime; - public readonly byte CommandNumber; - public readonly DvdTime PlaybackTime; - public readonly uint FirstSector; - public readonly uint FirstILVUEndSector; - public readonly uint LastVOBUStartSector; - public readonly uint LastSector; - - internal CellPlaybackInfo(BinaryReader br) - { - br.BaseStream.Seek(0x4, SeekOrigin.Current); - PlaybackTime = new DvdTime(br.ReadBytes(4)); - br.BaseStream.Seek(0x10, SeekOrigin.Current); - } - } -} diff --git a/DvdLib/Ifo/CellPositionInfo.cs b/DvdLib/Ifo/CellPositionInfo.cs deleted file mode 100644 index 216aa0f77..000000000 --- a/DvdLib/Ifo/CellPositionInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -#pragma warning disable CS1591 - -using System.IO; - -namespace DvdLib.Ifo -{ - public class CellPositionInfo - { - public readonly ushort VOBId; - public readonly byte CellId; - - internal CellPositionInfo(BinaryReader br) - { - VOBId = br.ReadUInt16(); - br.ReadByte(); - CellId = br.ReadByte(); - } - } -} diff --git a/DvdLib/Ifo/Chapter.cs b/DvdLib/Ifo/Chapter.cs deleted file mode 100644 index e786cb553..000000000 --- a/DvdLib/Ifo/Chapter.cs +++ /dev/null @@ -1,20 +0,0 @@ -#pragma warning disable CS1591 - -namespace DvdLib.Ifo -{ - public class Chapter - { - public ushort ProgramChainNumber { get; private set; } - - public ushort ProgramNumber { get; private set; } - - public uint ChapterNumber { get; private set; } - - public Chapter(ushort pgcNum, ushort programNum, uint chapterNum) - { - ProgramChainNumber = pgcNum; - ProgramNumber = programNum; - ChapterNumber = chapterNum; - } - } -} diff --git a/DvdLib/Ifo/Dvd.cs b/DvdLib/Ifo/Dvd.cs deleted file mode 100644 index 7f8ece47d..000000000 --- a/DvdLib/Ifo/Dvd.cs +++ /dev/null @@ -1,167 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; - -namespace DvdLib.Ifo -{ - public class Dvd - { - private readonly ushort _titleSetCount; - public readonly List<Title> Titles; - - private ushort _titleCount; - public readonly Dictionary<ushort, string> VTSPaths = new Dictionary<ushort, string>(); - public Dvd(string path) - { - Titles = new List<Title>(); - var allFiles = new DirectoryInfo(path).GetFiles(path, SearchOption.AllDirectories); - - var vmgPath = allFiles.FirstOrDefault(i => string.Equals(i.Name, "VIDEO_TS.IFO", StringComparison.OrdinalIgnoreCase)) ?? - allFiles.FirstOrDefault(i => string.Equals(i.Name, "VIDEO_TS.BUP", StringComparison.OrdinalIgnoreCase)); - - if (vmgPath == null) - { - foreach (var ifo in allFiles) - { - if (!string.Equals(ifo.Extension, ".ifo", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var nums = ifo.Name.Split('_', StringSplitOptions.RemoveEmptyEntries); - if (nums.Length >= 2 && ushort.TryParse(nums[1], out var ifoNumber)) - { - ReadVTS(ifoNumber, ifo.FullName); - } - } - } - else - { - using (var vmgFs = new FileStream(vmgPath.FullName, FileMode.Open, FileAccess.Read, FileShare.Read)) - { - using (var vmgRead = new BigEndianBinaryReader(vmgFs)) - { - vmgFs.Seek(0x3E, SeekOrigin.Begin); - _titleSetCount = vmgRead.ReadUInt16(); - - // read address of TT_SRPT - vmgFs.Seek(0xC4, SeekOrigin.Begin); - uint ttSectorPtr = vmgRead.ReadUInt32(); - vmgFs.Seek(ttSectorPtr * 2048, SeekOrigin.Begin); - ReadTT_SRPT(vmgRead); - } - } - - for (ushort titleSetNum = 1; titleSetNum <= _titleSetCount; titleSetNum++) - { - ReadVTS(titleSetNum, allFiles); - } - } - } - - private void ReadTT_SRPT(BinaryReader read) - { - _titleCount = read.ReadUInt16(); - read.BaseStream.Seek(6, SeekOrigin.Current); - for (uint titleNum = 1; titleNum <= _titleCount; titleNum++) - { - var t = new Title(titleNum); - t.ParseTT_SRPT(read); - Titles.Add(t); - } - } - - private void ReadVTS(ushort vtsNum, IReadOnlyList<FileInfo> allFiles) - { - var filename = string.Format(CultureInfo.InvariantCulture, "VTS_{0:00}_0.IFO", vtsNum); - - var vtsPath = allFiles.FirstOrDefault(i => string.Equals(i.Name, filename, StringComparison.OrdinalIgnoreCase)) ?? - allFiles.FirstOrDefault(i => string.Equals(i.Name, Path.ChangeExtension(filename, ".bup"), StringComparison.OrdinalIgnoreCase)); - - if (vtsPath == null) - { - throw new FileNotFoundException("Unable to find VTS IFO file"); - } - - ReadVTS(vtsNum, vtsPath.FullName); - } - - private void ReadVTS(ushort vtsNum, string vtsPath) - { - VTSPaths[vtsNum] = vtsPath; - - using (var vtsFs = new FileStream(vtsPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - { - using (var vtsRead = new BigEndianBinaryReader(vtsFs)) - { - // Read VTS_PTT_SRPT - vtsFs.Seek(0xC8, SeekOrigin.Begin); - uint vtsPttSrptSecPtr = vtsRead.ReadUInt32(); - uint baseAddr = (vtsPttSrptSecPtr * 2048); - vtsFs.Seek(baseAddr, SeekOrigin.Begin); - - ushort numTitles = vtsRead.ReadUInt16(); - vtsRead.ReadUInt16(); - uint endaddr = vtsRead.ReadUInt32(); - uint[] offsets = new uint[numTitles]; - for (ushort titleNum = 0; titleNum < numTitles; titleNum++) - { - offsets[titleNum] = vtsRead.ReadUInt32(); - } - - for (uint titleNum = 0; titleNum < numTitles; titleNum++) - { - uint chapNum = 1; - vtsFs.Seek(baseAddr + offsets[titleNum], SeekOrigin.Begin); - var t = Titles.FirstOrDefault(vtst => vtst.IsVTSTitle(vtsNum, titleNum + 1)); - if (t == null) - { - continue; - } - - do - { - t.Chapters.Add(new Chapter(vtsRead.ReadUInt16(), vtsRead.ReadUInt16(), chapNum)); - if (titleNum + 1 < numTitles && vtsFs.Position == (baseAddr + offsets[titleNum + 1])) - { - break; - } - - chapNum++; - } - while (vtsFs.Position < (baseAddr + endaddr)); - } - - // Read VTS_PGCI - vtsFs.Seek(0xCC, SeekOrigin.Begin); - uint vtsPgciSecPtr = vtsRead.ReadUInt32(); - vtsFs.Seek(vtsPgciSecPtr * 2048, SeekOrigin.Begin); - - long startByte = vtsFs.Position; - - ushort numPgcs = vtsRead.ReadUInt16(); - vtsFs.Seek(6, SeekOrigin.Current); - for (ushort pgcNum = 1; pgcNum <= numPgcs; pgcNum++) - { - byte pgcCat = vtsRead.ReadByte(); - bool entryPgc = (pgcCat & 0x80) != 0; - uint titleNum = (uint)(pgcCat & 0x7F); - - vtsFs.Seek(3, SeekOrigin.Current); - uint vtsPgcOffset = vtsRead.ReadUInt32(); - - var t = Titles.FirstOrDefault(vtst => vtst.IsVTSTitle(vtsNum, titleNum)); - if (t != null) - { - t.AddPgc(vtsRead, startByte + vtsPgcOffset, entryPgc, pgcNum); - } - } - } - } - } - } -} diff --git a/DvdLib/Ifo/DvdTime.cs b/DvdLib/Ifo/DvdTime.cs deleted file mode 100644 index d23140610..000000000 --- a/DvdLib/Ifo/DvdTime.cs +++ /dev/null @@ -1,39 +0,0 @@ -#pragma warning disable CS1591 - -using System; - -namespace DvdLib.Ifo -{ - public class DvdTime - { - public readonly byte Hour, Minute, Second, Frames, FrameRate; - - public DvdTime(byte[] data) - { - Hour = GetBCDValue(data[0]); - Minute = GetBCDValue(data[1]); - Second = GetBCDValue(data[2]); - Frames = GetBCDValue((byte)(data[3] & 0x3F)); - - if ((data[3] & 0x80) != 0) - { - FrameRate = 30; - } - else if ((data[3] & 0x40) != 0) - { - FrameRate = 25; - } - } - - private static byte GetBCDValue(byte data) - { - return (byte)((((data & 0xF0) >> 4) * 10) + (data & 0x0F)); - } - - public static explicit operator TimeSpan(DvdTime time) - { - int ms = (int)(((1.0 / (double)time.FrameRate) * time.Frames) * 1000.0); - return new TimeSpan(0, time.Hour, time.Minute, time.Second, ms); - } - } -} diff --git a/DvdLib/Ifo/Program.cs b/DvdLib/Ifo/Program.cs deleted file mode 100644 index 3d94fa7dc..000000000 --- a/DvdLib/Ifo/Program.cs +++ /dev/null @@ -1,16 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; - -namespace DvdLib.Ifo -{ - public class Program - { - public IReadOnlyList<Cell> Cells { get; } - - public Program(List<Cell> cells) - { - Cells = cells; - } - } -} diff --git a/DvdLib/Ifo/ProgramChain.cs b/DvdLib/Ifo/ProgramChain.cs deleted file mode 100644 index 83c0051b9..000000000 --- a/DvdLib/Ifo/ProgramChain.cs +++ /dev/null @@ -1,121 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace DvdLib.Ifo -{ - public enum ProgramPlaybackMode - { - Sequential, - Random, - Shuffle - } - - public class ProgramChain - { - private byte _programCount; - public readonly List<Program> Programs; - - private byte _cellCount; - public readonly List<Cell> Cells; - - public DvdTime PlaybackTime { get; private set; } - - public UserOperation ProhibitedUserOperations { get; private set; } - - public byte[] AudioStreamControl { get; private set; } // 8*2 entries - public byte[] SubpictureStreamControl { get; private set; } // 32*4 entries - - private ushort _nextProgramNumber; - - private ushort _prevProgramNumber; - - private ushort _goupProgramNumber; - - public ProgramPlaybackMode PlaybackMode { get; private set; } - - public uint ProgramCount { get; private set; } - - public byte StillTime { get; private set; } - - public byte[] Palette { get; private set; } // 16*4 entries - - private ushort _commandTableOffset; - - private ushort _programMapOffset; - private ushort _cellPlaybackOffset; - private ushort _cellPositionOffset; - - public readonly uint VideoTitleSetIndex; - - internal ProgramChain(uint vtsPgcNum) - { - VideoTitleSetIndex = vtsPgcNum; - Cells = new List<Cell>(); - Programs = new List<Program>(); - } - - internal void ParseHeader(BinaryReader br) - { - long startPos = br.BaseStream.Position; - - br.ReadUInt16(); - _programCount = br.ReadByte(); - _cellCount = br.ReadByte(); - PlaybackTime = new DvdTime(br.ReadBytes(4)); - ProhibitedUserOperations = (UserOperation)br.ReadUInt32(); - AudioStreamControl = br.ReadBytes(16); - SubpictureStreamControl = br.ReadBytes(128); - - _nextProgramNumber = br.ReadUInt16(); - _prevProgramNumber = br.ReadUInt16(); - _goupProgramNumber = br.ReadUInt16(); - - StillTime = br.ReadByte(); - byte pbMode = br.ReadByte(); - if (pbMode == 0) - { - PlaybackMode = ProgramPlaybackMode.Sequential; - } - else - { - PlaybackMode = ((pbMode & 0x80) == 0) ? ProgramPlaybackMode.Random : ProgramPlaybackMode.Shuffle; - } - - ProgramCount = (uint)(pbMode & 0x7F); - - Palette = br.ReadBytes(64); - _commandTableOffset = br.ReadUInt16(); - _programMapOffset = br.ReadUInt16(); - _cellPlaybackOffset = br.ReadUInt16(); - _cellPositionOffset = br.ReadUInt16(); - - // read position info - br.BaseStream.Seek(startPos + _cellPositionOffset, SeekOrigin.Begin); - for (int cellNum = 0; cellNum < _cellCount; cellNum++) - { - var c = new Cell(); - c.ParsePosition(br); - Cells.Add(c); - } - - br.BaseStream.Seek(startPos + _cellPlaybackOffset, SeekOrigin.Begin); - for (int cellNum = 0; cellNum < _cellCount; cellNum++) - { - Cells[cellNum].ParsePlayback(br); - } - - br.BaseStream.Seek(startPos + _programMapOffset, SeekOrigin.Begin); - var cellNumbers = new List<int>(); - for (int progNum = 0; progNum < _programCount; progNum++) cellNumbers.Add(br.ReadByte() - 1); - - for (int i = 0; i < cellNumbers.Count; i++) - { - int max = (i + 1 == cellNumbers.Count) ? _cellCount : cellNumbers[i + 1]; - Programs.Add(new Program(Cells.Where((c, idx) => idx >= cellNumbers[i] && idx < max).ToList())); - } - } - } -} diff --git a/DvdLib/Ifo/Title.cs b/DvdLib/Ifo/Title.cs deleted file mode 100644 index 29a0b95c7..000000000 --- a/DvdLib/Ifo/Title.cs +++ /dev/null @@ -1,70 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.IO; - -namespace DvdLib.Ifo -{ - public class Title - { - public uint TitleNumber { get; private set; } - - public uint AngleCount { get; private set; } - - public ushort ChapterCount { get; private set; } - - public byte VideoTitleSetNumber { get; private set; } - - private ushort _parentalManagementMask; - private byte _titleNumberInVTS; - private uint _vtsStartSector; // relative to start of entire disk - - public ProgramChain EntryProgramChain { get; private set; } - - public readonly List<ProgramChain> ProgramChains; - - public readonly List<Chapter> Chapters; - - public Title(uint titleNum) - { - ProgramChains = new List<ProgramChain>(); - Chapters = new List<Chapter>(); - Chapters = new List<Chapter>(); - TitleNumber = titleNum; - } - - public bool IsVTSTitle(uint vtsNum, uint vtsTitleNum) - { - return (vtsNum == VideoTitleSetNumber && vtsTitleNum == _titleNumberInVTS); - } - - internal void ParseTT_SRPT(BinaryReader br) - { - byte titleType = br.ReadByte(); - // TODO parse Title Type - - AngleCount = br.ReadByte(); - ChapterCount = br.ReadUInt16(); - _parentalManagementMask = br.ReadUInt16(); - VideoTitleSetNumber = br.ReadByte(); - _titleNumberInVTS = br.ReadByte(); - _vtsStartSector = br.ReadUInt32(); - } - - internal void AddPgc(BinaryReader br, long startByte, bool entryPgc, uint pgcNum) - { - long curPos = br.BaseStream.Position; - br.BaseStream.Seek(startByte, SeekOrigin.Begin); - - var pgc = new ProgramChain(pgcNum); - pgc.ParseHeader(br); - ProgramChains.Add(pgc); - if (entryPgc) - { - EntryProgramChain = pgc; - } - - br.BaseStream.Seek(curPos, SeekOrigin.Begin); - } - } -} diff --git a/DvdLib/Ifo/UserOperation.cs b/DvdLib/Ifo/UserOperation.cs deleted file mode 100644 index 5d111ebc0..000000000 --- a/DvdLib/Ifo/UserOperation.cs +++ /dev/null @@ -1,37 +0,0 @@ -#pragma warning disable CS1591 - -using System; - -namespace DvdLib.Ifo -{ - [Flags] - public enum UserOperation - { - None = 0, - TitleOrTimePlay = 1, - ChapterSearchOrPlay = 2, - TitlePlay = 4, - Stop = 8, - GoUp = 16, - TimeOrChapterSearch = 32, - PrevOrTopProgramSearch = 64, - NextProgramSearch = 128, - ForwardScan = 256, - BackwardScan = 512, - TitleMenuCall = 1024, - RootMenuCall = 2048, - SubpictureMenuCall = 4096, - AudioMenuCall = 8192, - AngleMenuCall = 16384, - ChapterMenuCall = 32768, - Resume = 65536, - ButtonSelectOrActive = 131072, - StillOff = 262144, - PauseOn = 524288, - AudioStreamChange = 1048576, - SubpictureStreamChange = 2097152, - AngleChange = 4194304, - KaraokeAudioPresentationModeChange = 8388608, - VideoPresentationModeChange = 16777216, - } -} diff --git a/DvdLib/Properties/AssemblyInfo.cs b/DvdLib/Properties/AssemblyInfo.cs deleted file mode 100644 index 6acd571d6..000000000 --- a/DvdLib/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; -using System.Resources; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("DvdLib")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Jellyfin Project")] -[assembly: AssemblyProduct("Jellyfin Server")] -[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs index c484dac54..db1190ae7 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 3edaabb70..9af28aa7c 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/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 7ffb7118a..aca239964 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.3"> + <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/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index 2dc079254..aab475153 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -7,6 +7,7 @@ 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; @@ -262,7 +263,7 @@ namespace Emby.Dlna.Main { _publisher = new SsdpDevicePublisher( _communicationsServer, - MediaBrowser.Common.System.OperatingSystem.Name, + Environment.OSVersion.Platform.ToString(), Environment.OSVersion.VersionString, _config.GetDlnaConfiguration().SendOnlyMatchedHost) { diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index 7b1f942c5..86db36337 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 f4a9a90af..b469c9cb0 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/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs index d00df781d..69ef6f645 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 bbfdccc90..86a564153 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 7b4429ab1..75fdedfea 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 bdae20b6b..ca304102f 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 97b34199e..5ea649dbf 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 54f62a157..e9161a6b7 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -338,7 +338,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 +461,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 +796,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 +866,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 80bc57a5d..f3973dad9 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.3"> + <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 d706be280..8cd5a126e 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 156a03c9e..307a84096 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 0ee633dcc..9a6c6e978 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 21d0da364..3219472ef 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 76b487f42..be0f79d33 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 804832040..6209cd46f 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.Notifications/CoreNotificationTypes.cs b/Emby.Notifications/CoreNotificationTypes.cs deleted file mode 100644 index 35aac3a11..000000000 --- a/Emby.Notifications/CoreNotificationTypes.cs +++ /dev/null @@ -1,123 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Notifications; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Notifications; - -namespace Emby.Notifications -{ - public class CoreNotificationTypes : INotificationTypeFactory - { - private readonly ILocalizationManager _localization; - - public CoreNotificationTypes(ILocalizationManager localization) - { - _localization = localization; - } - - public IEnumerable<NotificationTypeInfo> GetNotificationTypes() - { - var knownTypes = new NotificationTypeInfo[] - { - new NotificationTypeInfo - { - Type = nameof(NotificationType.ApplicationUpdateInstalled) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.InstallationFailed) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.PluginInstalled) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.PluginError) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.PluginUninstalled) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.PluginUpdateInstalled) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.ServerRestartRequired) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.TaskFailed) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.NewLibraryContent) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.AudioPlayback) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.VideoPlayback) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.AudioPlaybackStopped) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.VideoPlaybackStopped) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.UserLockedOut) - }, - new NotificationTypeInfo - { - Type = nameof(NotificationType.ApplicationUpdateAvailable) - } - }; - - foreach (var type in knownTypes) - { - Update(type); - } - - var systemName = _localization.GetLocalizedString("System"); - - return knownTypes.OrderByDescending(i => string.Equals(i.Category, systemName, StringComparison.OrdinalIgnoreCase)) - .ThenBy(i => i.Category) - .ThenBy(i => i.Name); - } - - private void Update(NotificationTypeInfo note) - { - note.Name = _localization.GetLocalizedString("NotificationOption" + note.Type); - - note.IsBasedOnUserEvent = note.Type.IndexOf("Playback", StringComparison.OrdinalIgnoreCase) != -1; - - if (note.Type.IndexOf("Playback", StringComparison.OrdinalIgnoreCase) != -1) - { - note.Category = _localization.GetLocalizedString("User"); - } - else if (note.Type.IndexOf("Plugin", StringComparison.OrdinalIgnoreCase) != -1) - { - note.Category = _localization.GetLocalizedString("Plugin"); - } - else if (note.Type.IndexOf("UserLockedOut", StringComparison.OrdinalIgnoreCase) != -1) - { - note.Category = _localization.GetLocalizedString("User"); - } - else - { - note.Category = _localization.GetLocalizedString("System"); - } - } - } -} diff --git a/Emby.Notifications/Emby.Notifications.csproj b/Emby.Notifications/Emby.Notifications.csproj deleted file mode 100644 index 138965c89..000000000 --- a/Emby.Notifications/Emby.Notifications.csproj +++ /dev/null @@ -1,35 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> - <PropertyGroup> - <ProjectGuid>{2E030C33-6923-4530-9E54-FA29FA6AD1A9}</ProjectGuid> - </PropertyGroup> - - <PropertyGroup> - <TargetFramework>net7.0</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <GenerateDocumentationFile>true</GenerateDocumentationFile> - </PropertyGroup> - - <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> - <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> - <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> - </ItemGroup> - - <!-- Code analyzers--> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> - <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/Emby.Notifications/NotificationConfigurationFactory.cs b/Emby.Notifications/NotificationConfigurationFactory.cs deleted file mode 100644 index 3fb3553d0..000000000 --- a/Emby.Notifications/NotificationConfigurationFactory.cs +++ /dev/null @@ -1,23 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Notifications; - -namespace Emby.Notifications -{ - public class NotificationConfigurationFactory : IConfigurationFactory - { - public IEnumerable<ConfigurationStore> GetConfigurations() - { - return new ConfigurationStore[] - { - new ConfigurationStore - { - Key = "notifications", - ConfigurationType = typeof(NotificationOptions) - } - }; - } - } -} diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs deleted file mode 100644 index 3763b1e92..000000000 --- a/Emby.Notifications/NotificationEntryPoint.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Events; -using Jellyfin.Extensions; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Notifications; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Notifications; -using Microsoft.Extensions.Logging; - -namespace Emby.Notifications -{ - /// <summary> - /// Creates notifications for various system events. - /// </summary> - public class NotificationEntryPoint : IServerEntryPoint - { - private readonly ILogger<NotificationEntryPoint> _logger; - private readonly IActivityManager _activityManager; - private readonly ILocalizationManager _localization; - private readonly INotificationManager _notificationManager; - private readonly ILibraryManager _libraryManager; - private readonly IServerApplicationHost _appHost; - private readonly IConfigurationManager _config; - - private readonly object _libraryChangedSyncLock = new object(); - private readonly List<BaseItem> _itemsAdded = new List<BaseItem>(); - - private Timer? _libraryUpdateTimer; - - private string[] _coreNotificationTypes; - - private bool _disposed = false; - - /// <summary> - /// Initializes a new instance of the <see cref="NotificationEntryPoint" /> class. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="activityManager">The activity manager.</param> - /// <param name="localization">The localization manager.</param> - /// <param name="notificationManager">The notification manager.</param> - /// <param name="libraryManager">The library manager.</param> - /// <param name="appHost">The application host.</param> - /// <param name="config">The configuration manager.</param> - public NotificationEntryPoint( - ILogger<NotificationEntryPoint> logger, - IActivityManager activityManager, - ILocalizationManager localization, - INotificationManager notificationManager, - ILibraryManager libraryManager, - IServerApplicationHost appHost, - IConfigurationManager config) - { - _logger = logger; - _activityManager = activityManager; - _localization = localization; - _notificationManager = notificationManager; - _libraryManager = libraryManager; - _appHost = appHost; - _config = config; - - _coreNotificationTypes = new CoreNotificationTypes(localization).GetNotificationTypes().Select(i => i.Type).ToArray(); - } - - /// <inheritdoc /> - public Task RunAsync() - { - _libraryManager.ItemAdded += OnLibraryManagerItemAdded; - _appHost.HasPendingRestartChanged += OnAppHostHasPendingRestartChanged; - _activityManager.EntryCreated += OnActivityManagerEntryCreated; - - return Task.CompletedTask; - } - - private async void OnAppHostHasPendingRestartChanged(object? sender, EventArgs e) - { - var type = NotificationType.ServerRestartRequired.ToString(); - - var notification = new NotificationRequest - { - NotificationType = type, - Name = string.Format( - CultureInfo.InvariantCulture, - _localization.GetLocalizedString("ServerNameNeedsToBeRestarted"), - _appHost.Name) - }; - - await SendNotification(notification, null).ConfigureAwait(false); - } - - private async void OnActivityManagerEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) - { - var entry = e.Argument; - - var type = entry.Type; - - if (string.IsNullOrEmpty(type) || !_coreNotificationTypes.Contains(type, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var userId = e.Argument.UserId; - - if (!userId.Equals(default) && !GetOptions().IsEnabledToMonitorUser(type, userId)) - { - return; - } - - var notification = new NotificationRequest - { - NotificationType = type, - Name = entry.Name, - Description = entry.Overview - }; - - await SendNotification(notification, null).ConfigureAwait(false); - } - - private NotificationOptions GetOptions() - { - return _config.GetConfiguration<NotificationOptions>("notifications"); - } - - private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) - { - if (!FilterItem(e.Item)) - { - return; - } - - lock (_libraryChangedSyncLock) - { - if (_libraryUpdateTimer is null) - { - _libraryUpdateTimer = new Timer( - LibraryUpdateTimerCallback, - null, - 5000, - Timeout.Infinite); - } - else - { - _libraryUpdateTimer.Change(5000, Timeout.Infinite); - } - - _itemsAdded.Add(e.Item); - } - } - - private bool FilterItem(BaseItem item) - { - if (item.IsFolder) - { - return false; - } - - if (!item.HasPathProtocol) - { - return false; - } - - if (item is IItemByName) - { - return false; - } - - return item.SourceType == SourceType.Library; - } - - private async void LibraryUpdateTimerCallback(object? state) - { - List<BaseItem> items; - - lock (_libraryChangedSyncLock) - { - items = _itemsAdded.ToList(); - _itemsAdded.Clear(); - _libraryUpdateTimer!.Dispose(); // Shouldn't be null as it just set off this callback - _libraryUpdateTimer = null; - } - - if (items.Count > 10) - { - items = items.GetRange(0, 10); - } - - foreach (var item in items) - { - var notification = new NotificationRequest - { - NotificationType = NotificationType.NewLibraryContent.ToString(), - Name = string.Format( - CultureInfo.InvariantCulture, - _localization.GetLocalizedString("ValueHasBeenAddedToLibrary"), - GetItemName(item)), - Description = item.Overview - }; - - await SendNotification(notification, item).ConfigureAwait(false); - } - } - - /// <summary> - /// Creates a human readable name for the item. - /// </summary> - /// <param name="item">The item.</param> - /// <returns>A human readable name for the item.</returns> - public static string GetItemName(BaseItem item) - { - var name = item.Name; - if (item is Episode episode) - { - if (episode.IndexNumber.HasValue) - { - name = string.Format( - CultureInfo.InvariantCulture, - "Ep{0} - {1}", - episode.IndexNumber.Value, - name); - } - - if (episode.ParentIndexNumber.HasValue) - { - name = string.Format( - CultureInfo.InvariantCulture, - "S{0}, {1}", - episode.ParentIndexNumber.Value, - name); - } - } - - if (item is IHasSeries hasSeries) - { - name = hasSeries.SeriesName + " - " + name; - } - - if (item is IHasAlbumArtist hasAlbumArtist) - { - var artists = hasAlbumArtist.AlbumArtists; - - if (artists.Count > 0) - { - name = artists[0] + " - " + name; - } - } - else if (item is IHasArtist hasArtist) - { - var artists = hasArtist.Artists; - - if (artists.Count > 0) - { - name = artists[0] + " - " + name; - } - } - - return name; - } - - private async Task SendNotification(NotificationRequest notification, BaseItem? relatedItem) - { - try - { - await _notificationManager.SendNotification(notification, relatedItem, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending notification"); - } - } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Releases unmanaged and optionally managed resources. - /// </summary> - /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _libraryUpdateTimer?.Dispose(); - } - - _libraryUpdateTimer = null; - - _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; - _appHost.HasPendingRestartChanged -= OnAppHostHasPendingRestartChanged; - _activityManager.EntryCreated -= OnActivityManagerEntryCreated; - - _disposed = true; - } - } -} diff --git a/Emby.Notifications/NotificationManager.cs b/Emby.Notifications/NotificationManager.cs deleted file mode 100644 index a5a76b2af..000000000 --- a/Emby.Notifications/NotificationManager.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Notifications; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Notifications; -using Microsoft.Extensions.Logging; - -namespace Emby.Notifications -{ - /// <summary> - /// NotificationManager class. - /// </summary> - public class NotificationManager : INotificationManager - { - private readonly ILogger<NotificationManager> _logger; - private readonly IUserManager _userManager; - private readonly IServerConfigurationManager _config; - - private INotificationService[] _services = Array.Empty<INotificationService>(); - private INotificationTypeFactory[] _typeFactories = Array.Empty<INotificationTypeFactory>(); - - /// <summary> - /// Initializes a new instance of the <see cref="NotificationManager" /> class. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="userManager">The user manager.</param> - /// <param name="config">The server configuration manager.</param> - public NotificationManager( - ILogger<NotificationManager> logger, - IUserManager userManager, - IServerConfigurationManager config) - { - _logger = logger; - _userManager = userManager; - _config = config; - } - - private NotificationOptions GetConfiguration() - { - return _config.GetConfiguration<NotificationOptions>("notifications"); - } - - /// <inheritdoc /> - public Task SendNotification(NotificationRequest request, CancellationToken cancellationToken) - { - return SendNotification(request, null, cancellationToken); - } - - /// <inheritdoc /> - public Task SendNotification(NotificationRequest request, BaseItem? relatedItem, CancellationToken cancellationToken) - { - var notificationType = request.NotificationType; - - var options = string.IsNullOrEmpty(notificationType) ? - null : - GetConfiguration().GetOptions(notificationType); - - var users = GetUserIds(request, options) - .Select(i => _userManager.GetUserById(i)) - .Where(i => relatedItem is null || relatedItem.IsVisibleStandalone(i)) - .ToArray(); - - var title = request.Name; - var description = request.Description; - - var tasks = _services.Where(i => IsEnabled(i, notificationType)) - .Select(i => SendNotification(request, i, users, title, description, cancellationToken)); - - return Task.WhenAll(tasks); - } - - private Task SendNotification( - NotificationRequest request, - INotificationService service, - IEnumerable<User> users, - string title, - string description, - CancellationToken cancellationToken) - { - users = users.Where(i => IsEnabledForUser(service, i)); - - var tasks = users.Select(i => SendNotification(request, service, title, description, i, cancellationToken)); - - return Task.WhenAll(tasks); - } - - private IEnumerable<Guid> GetUserIds(NotificationRequest request, NotificationOption? options) - { - if (request.SendToUserMode.HasValue) - { - switch (request.SendToUserMode.Value) - { - case SendToUserType.Admins: - return _userManager.Users.Where(i => i.HasPermission(PermissionKind.IsAdministrator)) - .Select(i => i.Id); - case SendToUserType.All: - return _userManager.UsersIds; - case SendToUserType.Custom: - return request.UserIds; - default: - throw new ArgumentException("Unrecognized SendToUserMode: " + request.SendToUserMode.Value); - } - } - - if (options is not null && !string.IsNullOrEmpty(request.NotificationType)) - { - var config = GetConfiguration(); - - return _userManager.Users - .Where(i => config.IsEnabledToSendToUser(request.NotificationType, i.Id.ToString("N", CultureInfo.InvariantCulture), i)) - .Select(i => i.Id); - } - - return request.UserIds; - } - - private async Task SendNotification( - NotificationRequest request, - INotificationService service, - string title, - string description, - User user, - CancellationToken cancellationToken) - { - var notification = new UserNotification - { - Date = request.Date, - Description = description, - Level = request.Level, - Name = title, - Url = request.Url, - User = user - }; - - _logger.LogDebug("Sending notification via {0} to user {1}", service.Name, user.Username); - - try - { - await service.SendNotification(notification, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending notification to {0}", service.Name); - } - } - - private bool IsEnabledForUser(INotificationService service, User user) - { - try - { - return service.IsEnabledForUser(user); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in IsEnabledForUser"); - return false; - } - } - - private bool IsEnabled(INotificationService service, string notificationType) - { - if (string.IsNullOrEmpty(notificationType)) - { - return true; - } - - return GetConfiguration().IsServiceEnabled(service.Name, notificationType); - } - - /// <inheritdoc /> - public void AddParts(IEnumerable<INotificationService> services, IEnumerable<INotificationTypeFactory> notificationTypeFactories) - { - _services = services.ToArray(); - _typeFactories = notificationTypeFactories.ToArray(); - } - - /// <inheritdoc /> - public List<NotificationTypeInfo> GetNotificationTypes() - { - var list = _typeFactories.Select(i => - { - try - { - return i.GetNotificationTypes().ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in GetNotificationTypes"); - return new List<NotificationTypeInfo>(); - } - }).SelectMany(i => i).ToList(); - - var config = GetConfiguration(); - - foreach (var i in list) - { - i.Enabled = config.IsEnabled(i.Type); - } - - return list; - } - - /// <inheritdoc /> - public IEnumerable<NameIdPair> GetNotificationServices() - { - return _services.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Name.GetMD5().ToString("N", CultureInfo.InvariantCulture) - }).OrderBy(i => i.Name); - } - } -} diff --git a/Emby.Notifications/Properties/AssemblyInfo.cs b/Emby.Notifications/Properties/AssemblyInfo.cs deleted file mode 100644 index 5c82c90c4..000000000 --- a/Emby.Notifications/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; -using System.Resources; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Emby.Notifications")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Jellyfin Project")] -[assembly: AssemblyProduct("Jellyfin Server")] -[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index 34bc8f32f..0f97a0686 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.3"> + <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 985a127d5..a4deeddb7 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 7b3d07dfc..080c44829 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; @@ -19,7 +18,6 @@ using Emby.Dlna; using Emby.Dlna.Main; using Emby.Dlna.Ssdp; using Emby.Naming.Common; -using Emby.Notifications; using Emby.Photos; using Emby.Server.Implementations.Channels; using Emby.Server.Implementations.Collections; @@ -70,7 +68,6 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Notifications; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Plugins; @@ -118,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; @@ -134,7 +127,6 @@ namespace Emby.Server.Implementations private readonly IPluginManager _pluginManager; private List<Type> _creatingInstances; - private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; /// <summary> @@ -143,8 +135,6 @@ namespace Emby.Server.Implementations /// <value>All concrete types.</value> private Type[] _allConcreteTypes; - private DeviceId _deviceId; - private bool _disposed = false; /// <summary> @@ -168,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); @@ -193,30 +184,11 @@ namespace Emby.Server.Implementations /// </summary> private string PublishedServerUrl => _startupConfig[AddressOverrideKey]; - /// <summary> - /// Gets a value indicating whether this instance can self restart. - /// </summary> - public bool CanSelfRestart => _startupOptions.RestartPath is not null; - 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. @@ -293,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; @@ -454,7 +418,7 @@ namespace Emby.Server.Implementations ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated; - _mediaEncoder.SetFFmpegPath(); + Resolve<IMediaEncoder>().SetFFmpegPath(); Logger.LogInformation("ServerId: {ServerId}", SystemId); @@ -624,8 +588,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<IUserViewManager, UserViewManager>(); - serviceCollection.AddSingleton<INotificationManager, NotificationManager>(); - serviceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); serviceCollection.AddSingleton<IChapterManager, ChapterManager>(); @@ -654,7 +616,7 @@ namespace Emby.Server.Implementations /// <returns>A task representing the service initialization operation.</returns> public async Task InitializeServices() { - var jellyfinDb = await Resolve<IDbContextFactory<JellyfinDb>>().CreateDbContextAsync().ConfigureAwait(false); + var jellyfinDb = await Resolve<IDbContextFactory<JellyfinDbContext>>().CreateDbContextAsync().ConfigureAwait(false); await using (jellyfinDb.ConfigureAwait(false)) { if ((await jellyfinDb.Database.GetPendingMigrationsAsync().ConfigureAwait(false)).Any()) @@ -668,7 +630,6 @@ namespace Emby.Server.Implementations var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>(); await localizationManager.LoadAll().ConfigureAwait(false); - _mediaEncoder = Resolve<IMediaEncoder>(); _sessionManager = Resolve<ISessionManager>(); SetStaticProperties(); @@ -679,36 +640,6 @@ namespace Emby.Server.Implementations 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}", MediaBrowser.Common.System.OperatingSystem.Name); - 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)) @@ -795,13 +726,7 @@ 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>()); - - Resolve<INotificationManager>().AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>()); } /// <summary> @@ -935,17 +860,13 @@ namespace Emby.Server.Implementations /// </summary> public void Restart() { - if (!CanSelfRestart) - { - throw new PlatformNotSupportedException("The server is unable to self-restart. Please restart manually."); - } - if (IsShuttingDown) { return; } IsShuttingDown = true; + _pluginManager.UnloadAssemblies(); Task.Run(async () => { @@ -1004,9 +925,6 @@ namespace Emby.Server.Implementations // Local metadata yield return typeof(BoxSetXmlSaver).Assembly; - // Notifications - yield return typeof(NotificationManager).Assembly; - // Xbmc yield return typeof(ArtistNfoProvider).Assembly; @@ -1045,15 +963,11 @@ namespace Emby.Server.Implementations ItemsByNamePath = ApplicationPaths.InternalMetadataPath, InternalMetadataPath = ApplicationPaths.InternalMetadataPath, CachePath = ApplicationPaths.CachePath, - OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), - OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name, - CanSelfRestart = CanSelfRestart, CanLaunchWebBrowser = CanLaunchWebBrowser, TranscodingTempPath = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, LocalAddress = GetSmartApiUrl(request), SupportsLibraryMonitor = true, - SystemArchitecture = RuntimeInformation.OSArchitecture, PackageName = _startupOptions.PackageName }; } @@ -1065,7 +979,6 @@ namespace Emby.Server.Implementations Version = ApplicationVersionString, ProductName = ApplicationProductName, Id = SystemId, - OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), ServerName = FriendlyName, LocalAddress = GetSmartApiUrl(request), StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted @@ -1275,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 85ccbc028..961e225e9 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 b53c8ca51..179683055 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -206,8 +206,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 +222,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 ff5602f24..6b8b1a620 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/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index bc703fe90..3bf4d07c5 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -586,7 +586,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 +594,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 +608,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()) @@ -1195,7 +1197,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) { @@ -3202,7 +3204,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 +3680,6 @@ namespace Emby.Server.Implementations.Data if (statement is not null) { nameContains = FixUnicodeChars(nameContains); - statement.TryBind("@NameContains", "%" + GetCleanValue(nameContains) + "%"); } } @@ -3803,13 +3805,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 +3821,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 +3837,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 +3853,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 +3869,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 +3885,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 +3901,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 +3916,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 +3931,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 +3946,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 +3962,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 +3970,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.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) + { + ratingClauseBuilder.Append(" OR ("); } - } - if (query.HasParentalRating.HasValue) - { - if (query.HasParentalRating.Value) + if (query.MinParentalRating.HasValue) { - whereClauses.Add("InheritedParentalRatingValue > 0"); + ratingClauseBuilder.Append("InheritedParentalRatingValue >= @MinParentalRating"); + statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); } - else + + if (query.MaxParentalRating.HasValue) { - whereClauses.Add("InheritedParentalRatingValue = 0"); + 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)) + { + 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 +4101,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 +4169,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 +4181,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 +4212,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 +4237,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 +4262,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 +4287,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 +4301,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 +4378,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 +4391,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 +4418,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 +4546,7 @@ namespace Emby.Server.Implementations.Data return whereClauses; } +#nullable disable /// <summary> /// Formats a where clause for the specified provider. @@ -5440,6 +5400,9 @@ AND Type = @InternalPersonType)"); list.AddRange(inheritedTags.Select(i => (6, i))); + // Remove all invalid values. + list.RemoveAll(i => string.IsNullOrEmpty(i.Item2)); + return list; } diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 5103b1fbf..45270de89 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -83,22 +83,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 +122,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); } diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 7accc3b8b..b8655c760 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -7,7 +7,6 @@ <ItemGroup> <ProjectReference Include="..\Emby.Naming\Emby.Naming.csproj" /> - <ProjectReference Include="..\Emby.Notifications\Emby.Notifications.csproj" /> <ProjectReference Include="..\Jellyfin.Api\Jellyfin.Api.csproj" /> <ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" /> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> @@ -23,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> @@ -54,13 +53,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <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 05d0a9b79..2e3988f9e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -276,25 +276,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 +314,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 e724618b3..d32759017 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/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index 3769ae4dd..b7bcaace1 100644 --- a/Emby.Server.Implementations/IStartupOptions.cs +++ b/Emby.Server.Implementations/IStartupOptions.cs @@ -21,16 +21,6 @@ namespace Emby.Server.Implementations string? PackageName { get; } /// <summary> - /// Gets the value of the --restartpath command line option. - /// </summary> - string? RestartPath { get; } - - /// <summary> - /// Gets the value of the --restartargs command line option. - /// </summary> - string? RestartArgs { get; } - - /// <summary> /// Gets the value of the --published-server-url command line option. /// </summary> string? PublishedServerUrl { get; } diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs index 6fc7f1ac3..84c21931c 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 4376bd356..90f7568a9 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 968bf5fa3..c9b41f819 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 a3c66dc79..e5c520ca2 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 }); } } @@ -1277,7 +1279,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 +1437,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 +1457,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)) { @@ -1602,7 +1604,7 @@ namespace Emby.Server.Implementations.Library { _logger.LogError(ex, "Error getting intros"); - return new List<IntroInfo>(); + return Enumerable.Empty<IntroInfo>(); } } @@ -2876,7 +2878,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 +2920,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 +3087,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/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index 06621700a..a74f82475 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 a922e3685..bbc70701c 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 2538c2b5b..c858dc53d 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 e8615e7db..4fac91bf1 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,25 @@ 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)) { - 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 30c52e19d..0b255f673 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -4,6 +4,7 @@ using System.IO; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -25,11 +26,12 @@ 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) { _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[] { new GenericVideoResolver<Video>(logger, namingOptions, directoryService) }; } /// <summary> diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index 5e33b402d..ba320266a 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 1522cd3ae..ea980b992 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 e11fb262e..9026160ff 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/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index 0fcc5070b..392ee4c77 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/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 1137625f4..17f1d1905 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -111,10 +111,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; @@ -286,7 +286,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/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 3f7914d3b..b5e742f98 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -570,15 +570,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 e874990da..066afb956 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 4003468d0..ee039ff0f 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 5327b3d74..98bbc1540 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/LegacyHdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs index 80d9d0724..3450f971f 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/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index a423ec8f4..f2020e05f 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)) @@ -168,28 +172,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts string numberString = null; string attributeValue; - if (attributes.TryGetValue("tvg-chno", out attributeValue)) + if (attributes.TryGetValue("tvg-chno", out 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 +207,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 +255,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 +280,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 +312,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/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 56c4e7d39..3af124678 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/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 64cb36fd8..13b99cc99 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Оптимизирай базата данни", "TaskKeyframeExtractorDescription": "Извличат се ключови кадри от видеофайловете ,за да се създаде по точен ХЛС списък . Задачата може да отнеме много време.", "TaskKeyframeExtractor": "Извличане на ключови кадри", - "External": "Външен" + "External": "Външен", + "HearingImpaired": "Увреден слух" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 8ad9e8c71..8bd3c5def 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 afffdf3bf..5e41462db 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": "Último contenido en", "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 d6078c9c6..3d5c04633 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 026648af4..8e4bba25b 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/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index bd8cec710..ac9da1dd1 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 182b43ffc..a0e2f04a1 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -67,5 +67,11 @@ "Plugin": "प्लग-इन", "Playlists": "प्लेलिस्ट", "Photos": "तस्वीरें", - "External": "बाहरी" + "External": "बाहरी", + "PluginUpdatedWithName": "{0} अपडेट हुए", + "ScheduledTaskStartedWithName": "{0} शुरू हुए", + "Songs": "गाने", + "UserStartedPlayingItemWithValues": "{0} {2} पर {1} खेल रहे हैं", + "UserStoppedPlayingItemWithValues": "{0} ने {2} पर {1} खेलना खत्म किया", + "StartupEmbyServerIsLoading": "जेलीफ़िन सर्वर लोड हो रहा है। कृपया शीघ्र ही पुन: प्रयास करें।" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 695c0f404..87ce07da3 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 b262a8b42..a40f49506 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/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index e03747cbe..01a2ab273 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -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/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index 506c14fdc..87800a2fe 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -13,5 +13,16 @@ "DeviceOfflineWithName": "{0} abandoned ship", "AppDeviceValues": "Captain: {0}, Ship: {1}", "CameraImageUploadedFrom": "Yer looking glass has glimpsed another painting from {0}", - "Collections": "Barrels" + "Collections": "Barrels", + "ItemAddedWithName": "{0} is now with yer treasure", + "Default": "Normal-like", + "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}", + "Favorites": "Finest Loot", + "ItemRemovedWithName": "{0} was taken from yer treasure", + "LabelIpAddressValue": "Ship's coordinates: {0}", + "Genres": "types o' booty", + "TaskDownloadMissingSubtitlesDescription": "Scours the seven seas o' the internet for subtitles that be missin' based on the captain's map o' metadata.", + "HeaderAlbumArtists": "Buccaneers o' the musical arts", + "HeaderFavoriteAlbums": "Beloved booty o' musical adventures", + "HeaderFavoriteArtists": "Treasured scallywags o' the creative seas" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index 39229f45f..92e0d34ae 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -121,5 +121,6 @@ "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" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 65cf29e80..839bbcb6d 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": "Наследуемое", @@ -70,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} - неудачна", "ScheduledTaskStartedWithName": "{0} - запущена", "ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}", - "Shows": "Передачи", + "Shows": "Телешоу", "Songs": "Композиции", "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index d845accac..4c23f71ef 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/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 318a0f3cf..785e6b226 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/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 92ce616f2..ff77fb8c5 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/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index baa9ecc1c..cdc25ec7c 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -9,15 +9,15 @@ "Channels": "頻道", "ChapterNameValue": "章節 {0}", "Collections": "合輯", - "DeviceOfflineWithName": "{0} 已經斷開連結", + "DeviceOfflineWithName": "{0} 已經斷開連接", "DeviceOnlineWithName": "{0} 已經連接", - "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗", + "FailedLoginAttemptWithUserName": "{0} 登入失敗", "Favorites": "我的最愛", "Folders": "資料夾", "Genres": "風格", "HeaderAlbumArtists": "專輯藝人", "HeaderContinueWatching": "繼續觀看", - "HeaderFavoriteAlbums": "最愛專輯", + "HeaderFavoriteAlbums": "最愛的專輯", "HeaderFavoriteArtists": "最愛的藝人", "HeaderFavoriteEpisodes": "最愛的劇集", "HeaderFavoriteShows": "最愛的節目", @@ -44,10 +44,10 @@ "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季數", "NewVersionIsAvailable": "新版本的 Jellyfin 伺服器可供下載。", - "NotificationOptionApplicationUpdateAvailable": "有可用的應用程式更新", + "NotificationOptionApplicationUpdateAvailable": "有可用的更新", "NotificationOptionApplicationUpdateInstalled": "應用程式已更新", - "NotificationOptionAudioPlayback": "開始播放音頻", - "NotificationOptionAudioPlaybackStopped": "已停止播放音頻", + "NotificationOptionAudioPlayback": "開始播放音訊", + "NotificationOptionAudioPlaybackStopped": "已停止播放音訊", "NotificationOptionCameraImageUploaded": "相片已上傳", "NotificationOptionInstallationFailed": "安裝失敗", "NotificationOptionNewLibraryContent": "已添加新内容", diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index b418c7877..166b71b4a 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.Any(x => x.Value == 0)) + { + ratings.Add(new ParentalRating("Approved", 0)); + } + + // Matches PG (this has different age restrictions depending on country) + if (!ratings.Any(x => x.Value == 10)) + { + ratings.Add(new ParentalRating("10", 10)); + } + + // Matches PG-13 + if (!ratings.Any(x => x.Value == 13)) + { + ratings.Add(new ParentalRating("13", 13)); + } + + // Matches TV-14 + if (!ratings.Any(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.Any(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.Any(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 000000000..36886ba76 --- /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 11f4ed94c..4ab808ae9 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 d3937caf7..d171a7132 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 e5edaf62c..5ec1eb262 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 5aef0580f..336ee2806 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 9684fa052..e1e96c590 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 f944a140d..d633a5dab 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 5364ae1f2..4ef63b2ea 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 887d91ba6..0bc1d3f7d 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 782785890..7ff92f259 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 f586a3fa9..774a70589 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 c1f7d0452..75b1c2058 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 e42be5cd4..6ef2e5012 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 a8fc2d143..bfb5fdaae 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 d546bff53..e26b32b67 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 785a8ba22..305912f23 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 8c005092e..44f372b2d 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 127407be8..c8f8e93db 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 bba99b764..f617f0c39 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 4089b282f..44c23e248 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 1bc94affd..8b264070b 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 1443c07df..e129c3561 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 6c8005b3f..75b1c2058 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 34c897fe3..d103ddf42 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/Plugins/PluginLoadContext.cs b/Emby.Server.Implementations/Plugins/PluginLoadContext.cs new file mode 100644 index 000000000..d04e9cf68 --- /dev/null +++ b/Emby.Server.Implementations/Plugins/PluginLoadContext.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Emby.Server.Implementations.Plugins; + +/// <summary> +/// A custom <see cref="AssemblyLoadContext"/> for loading Jellyfin plugins. +/// </summary> +public class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + /// <summary> + /// Initializes a new instance of the <see cref="PluginLoadContext"/> class. + /// </summary> + /// <param name="path">The path of the plugin assembly.</param> + public PluginLoadContext(string path) : base(true) + { + _resolver = new AssemblyDependencyResolver(path); + } + + /// <inheritdoc /> + protected override Assembly? Load(AssemblyName assemblyName) + { + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath is not null) + { + return LoadFromAssemblyPath(assemblyPath); + } + + return null; + } +} diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 14e7c2269..7c23254a1 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; +using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -30,6 +31,7 @@ namespace Emby.Server.Implementations.Plugins { private readonly string _pluginsPath; private readonly Version _appVersion; + private readonly List<AssemblyLoadContext> _assemblyLoadContexts; private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger<PluginManager> _logger; private readonly IApplicationHost _appHost; @@ -76,6 +78,8 @@ namespace Emby.Server.Implementations.Plugins _appHost = appHost; _minimumVersion = new Version(0, 0, 0, 1); _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>(); + + _assemblyLoadContexts = new List<AssemblyLoadContext>(); } private IHttpClientFactory HttpClientFactory @@ -119,43 +123,78 @@ 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 { - assembly = Assembly.LoadFrom(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; } } } + /// <inheritdoc /> + public void UnloadAssemblies() + { + foreach (var assemblyLoadContext in _assemblyLoadContexts) + { + assemblyLoadContext.Unload(); + } + } + /// <summary> /// Creates all the plugin instances. /// </summary> diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index ee9aa8569..1af2c96d2 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 63f0beb10..42c30c959 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/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 1efacd856..1f3cb9b63 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { private readonly ILogger<OptimizeDatabaseTask> _logger; private readonly ILocalizationManager _localization; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; /// <summary> /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks public OptimizeDatabaseTask( ILogger<OptimizeDatabaseTask> logger, ILocalizationManager localization, - IDbContextFactory<JellyfinDb> provider) + IDbContextFactory<JellyfinDbContext> provider) { _logger = logger; _localization = localization; diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index aebb55907..4e427b1a4 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/Sorting/RuntimeComparer.cs b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs index 646bafbb5..753e58324 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 0bd9600b9..5b6c64f63 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 628b9b3dd..19abafe19 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 c3df7c47e..2759d20de 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 457c06271..89d10f3d2 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/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 967f90b55..f0e173f0b 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/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs index fbe68b6b9..a6c89bab8 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 244a29da4..57433202e 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 4dcf5976a..cbd32ed82 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 d0238424a..f4a6dcdaf 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 514e7ce97..bf64fef5d 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 9fc25f192..7ce09c299 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 d8e4141ac..c728f68e0 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 1e5b542e2..f145a061e 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 5b15cb1a5..c03ed740c 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 6857d45ec..10dec0c00 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 d4b1ffb06..741b88ea9 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 8e5e66d64..000000000 --- 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 be77b7a4e..de271ab64 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 7cea00b69..5ba1bc330 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 b61680ab1..000000000 --- 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 b0a72a9de..000000000 --- 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 31482a930..000000000 --- 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 00aaec334..000000000 --- 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 dd0bd4ec2..000000000 --- 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 f7366bd7a..000000000 --- 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 51ba637b6..000000000 --- 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 90b76ee99..28ba25850 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,27 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy return Task.CompletedTask; } - var validated = ValidateClaims(context.User); - if (validated && context.User.IsInRole(UserRoles.Administrator)) + if (requirement.RequireAdmin && !context.User.IsInRole(UserRoles.Administrator)) + { + context.Fail(); + return Task.CompletedTask; + } + + if (!requirement.ValidateParentalSchedule) { context.Succeed(requirement); + return Task.CompletedTask; } - else + + var user = _userManager.GetUserById(context.User.GetUserId()); + if (user is null) { - context.Fail(); + 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 000000000..6252a2feb --- /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 a7623556a..000000000 --- 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 cdad74270..000000000 --- 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 14722aa57..6ed6fc90b 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 d9c64d01c..f633c69d8 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 d772ec554..000000000 --- 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 761127fa4..000000000 --- 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 b235c4b63..000000000 --- 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 cfff1cc0c..000000000 --- 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 cdd7d8a52..75ec9fcec 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 6fab4c0ad..220b223b3 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 000000000..e72bec46f --- /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 000000000..4694556eb --- /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 e327831fe..5b4bd0adb 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 bac3379e7..d5c2253e4 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 8323312e5..73c4acb88 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 a72eeea28..53841b0c4 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 d9a536e7d..41c7b7cd0 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 ae45f647f..c3d02976e 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 024a15349..991f8cbf2 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 c8ac2ed52..c9d2f67f9 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 94f7a7b82..968193a6f 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 d3ea41201..3c2c4b4db 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 d5b589a3f..11c4ac376 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 ed073a687..2c5dbacbb 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 effc9ed7a..2db04afb8 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 a00ac1b0a..9007dfc41 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 3894e6c5f..076084c7a 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 aad60cf5c..aa0dff212 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 67cceb4a8..6f0006832 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 07e0590a1..415385463 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 96c492b3e..95b296fae 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 ba9a57f1d..16c77a923 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -9,7 +9,6 @@ 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; @@ -20,6 +19,8 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -30,2025 +31,2029 @@ 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); - - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } - - streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + .ConfigureAwait(false); - 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); - return segments; + internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + { + 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++) + { + 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)); + } + + 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 \"{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", + EncodingUtils.NormalizePath(outputTsArg), + EncodingUtils.NormalizePath(outputPath)).Trim(); + } + + /// <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; } - /// <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 audioCodec = _encodingHelper.GetAudioEncoder(state); + + if (!state.IsOutputVideo) { - if (state.AudioStream is null) + if (EncodingHelper.IsCopyCodec(audioCodec)) { - return string.Empty; - } + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - var audioCodec = _encodingHelper.GetAudioEncoder(state); + return "-acodec copy -strict -2" + bitStreamArgs; + } - if (!state.IsOutputVideo) - { - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + var audioTranscodeParams = string.Empty; - return "-acodec copy -strict -2" + bitStreamArgs; - } + audioTranscodeParams += "-acodec " + audioCodec; - var audioTranscodeParams = string.Empty; + if (state.OutputAudioBitrate.HasValue) + { + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); + } - audioTranscodeParams += "-acodec " + audioCodec; + if (state.OutputAudioChannels.HasValue) + { + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); + } - if (state.OutputAudioBitrate.HasValue) - { - audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); - } + if (state.OutputAudioSampleRate.HasValue) + { + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } - if (state.OutputAudioChannels.HasValue) - { - audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); - } + audioTranscodeParams += " -vn"; + return audioTranscodeParams; + } - if (state.OutputAudioSampleRate.HasValue) - { - audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } + // dts, flac, opus and truehd are experimental in mp4 muxer + var strictArgs = string.Empty; - audioTranscodeParams += " -vn"; - return audioTranscodeParams; - } + 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)) + { + strictArgs = " -strict -2"; + } - // dts, flac and opus are experimental in mp4 muxer - var strictArgs = string.Empty; + 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 (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)) + if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) { - strictArgs = " -strict -2"; + return copyArgs + " -copypriorss:a:0 0"; } - 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; + return copyArgs; + } - if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) - { - return copyArgs + " -copypriorss:a:0 0"; - } + var args = "-codec:a:0 " + audioCodec + strictArgs; - return copyArgs; - } + var channels = state.OutputAudioChannels; - var args = "-codec:a:0 " + audioCodec + strictArgs; + 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 channels = state.OutputAudioChannels; + var bitrate = state.OutputAudioBitrate; - 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; - } + if (bitrate.HasValue) + { + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + } - var bitrate = state.OutputAudioBitrate; + if (state.OutputAudioSampleRate.HasValue) + { + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } - if (bitrate.HasValue) - { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); - } + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); - if (state.OutputAudioSampleRate.HasValue) - { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } + 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) + && (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))) { - 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)) - { - _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); - return GetSegmentResult(state, segmentPath, transcodingJob); - } - } - else + if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) { - segmentExists = System.IO.File.Exists(segmentPath); - if (segmentExists) - { - continue; // avoid unnecessary waiting if segment just became available - } + _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); + return GetSegmentResult(state, segmentPath, transcodingJob); } - - 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); + return Task.CompletedTask; + }); - if (file is null) - { - return null; - } - - 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); + + 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)); - private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + var filePrefix = 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) { - var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); + 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 6c78a7987..8c9ee1a19 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 17d136384..dac07429f 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,6 +1,7 @@ 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 +13,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 611643bd8..da60f2c60 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 50fee233a..d7cec865e 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 f866655c0..3c5f18af5 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -30,2116 +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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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, - [FromQuery] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? addPlayedIndicator, - [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, - addPlayedIndicator, - 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, - bool? addPlayedIndicator, - 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; - addPlayedIndicator = true; + 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, - AddPlayedIndicator = addPlayedIndicator ?? false, - 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 2e0d3cb99..4dc2a4253 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 34893d682..b030e74dd 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 {0}-{1}: {2}", - item.Id, - item.Name, - JsonSerializer.Serialize(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 0dc3fbd05..b8f6e91ad 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 af3d779f5..ece053a9a 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -20,332 +20,378 @@ 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; + item.Tags = request.Tags; - item.CriticRating = request.CriticRating; + if (request.Taglines is not null) + { + item.Tagline = request.Taglines.FirstOrDefault(); + } - item.CommunityRating = request.CommunityRating; - item.IndexNumber = request.IndexNumber; - item.ParentIndexNumber = request.ParentIndexNumber; - item.Overview = request.Overview; - item.Genres = request.Genres; + if (request.Studios is not null) + { + item.Studios = request.Studios.Select(x => x.Name).ToArray(); + } - if (item is Episode episode) - { - episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber; - episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber; - episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber; - } + if (request.DateCreated.HasValue) + { + item.DateCreated = NormalizeDateTime(request.DateCreated.Value); + } - item.Tags = request.Tags; + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; + item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; + item.ProductionYear = request.ProductionYear; - if (request.Taglines is not null) - { - item.Tagline = request.Taglines.FirstOrDefault(); - } + request.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; + item.OfficialRating = request.OfficialRating; + item.CustomRating = request.CustomRating; - 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.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.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.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.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 +403,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 717ddc32b..377526729 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.Playlist + || 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)); + 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 196d509fb..bf59febed 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -4,13 +4,12 @@ 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; @@ -37,773 +36,792 @@ 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); - - if (item is null) + IEnumerable<BaseItem> themeItems; + + 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; + + while (true) + { + themeItems = item.GetThemeVideos(); - return new ThemeMediaResult + if (themeItems.Any() || !inheritFromParent) { - 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); - - if (item is null) + break; + } + + var parent = item.GetParent(); + if (parent is null) { - return NotFound("Item not found."); + break; } - IEnumerable<BaseItem> themeItems; + item = parent; + } - while (true) - { - themeItems = item.GetThemeVideos(); + var dtoOptions = new DtoOptions().AddClientFields(User); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); - if (themeItems.Any() || !inheritFromParent) - { - break; - } + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } - var parent = item.GetParent(); - if (parent is null) - { - break; - } + /// <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); - item = parent; - } + var themeVideos = GetThemeVideos( + itemId, + userId, + inheritFromParent); - var dtoOptions = new DtoOptions().AddClientFields(User); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); + if (themeSongs.Result is NotFoundObjectResult || themeVideos.Result is NotFoundObjectResult) + { + return NotFound(); + } - 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"); - } + 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"); + } + + 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)] + public ActionResult DeleteItem(Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + var user = _userManager.GetUserById(User.GetUserId()); + + if (!item.CanDelete(user)) + { + return Unauthorized("Unauthorized access"); + } + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + + return NoContent(); + } + + /// <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)] + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + if (ids.Length == 0) + { 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(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItem(Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); + foreach (var i in ids) + { + var item = _libraryManager.GetItemById(i); var user = _userManager.GetUserById(User.GetUserId()); if (!item.CanDelete(user)) { - return Unauthorized("Unauthorized access"); + if (ids.Length > 1) + { + return Unauthorized("Unauthorized access"); + } + + continue; } _libraryManager.DeleteItem( item, new DeleteOptions { DeleteFileLocation = true }, true); - - return NoContent(); } - /// <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) - { - if (ids.Length == 0) - { - return NoContent(); - } - - foreach (var i in ids) - { - var item = _libraryManager.GetItemById(i); - var user = _userManager.GetUserById(User.GetUserId()); + return NoContent(); + } - if (!item.CanDelete(user)) - { - if (ids.Length > 1) - { - 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] + [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); - continue; - } + 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; + } - _libraryManager.DeleteItem( - item, - new DeleteOptions { DeleteFileLocation = true }, - true); - } + /// <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); - return NoContent(); + if (item is null) + { + return NotFound("Item not found"); } - /// <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) - { - var user = userId is null || 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(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); - - if (item is null) - { - return NotFound("Item not found"); - } - - var baseItemDtos = new List<BaseItemDto>(); + 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)); - /// <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)); + parent = parent?.GetParent(); } - /// <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(); + return baseItemDtos; + } - if (isHidden.HasValue) - { - var val = isHidden.Value; + /// <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)); + } - 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(); + + if (isHidden.HasValue) + { + var val = isHidden.Value; - var dtoOptions = new DtoOptions().AddClientFields(User); - var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); - return new QueryResult<BaseItemDto>(resultArray); + items = items.Where(i => i.IsHidden == val).ToList(); } - /// <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(); + var dtoOptions = new DtoOptions().AddClientFields(User); + var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); + return new QueryResult<BaseItemDto>(resultArray); + } - foreach (var item in series) + /// <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) { - _libraryMonitor.ReportFileSystemChanged(item.Path); + EnableImages = false } + }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); - return NoContent(); + foreach (var item in series) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); } - /// <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) - { - var movies = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Movie }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }); - - 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) + /// <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="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) + foreach (var item in movies) { - foreach (var item in dto.Updates) - { - _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); - } + _libraryMonitor.ReportFileSystemChanged(item.Path); + } - return NoContent(); + return NoContent(); + } + + /// <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.")); } - /// <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(); - } + return NoContent(); + } - var user = _userManager.GetUserById(User.GetUserId()); + /// <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(); + } - if (user is not null) + var user = _userManager.GetUserById(User.GetUserId()); + + if (user is not null) + { + if (!item.CanDownload(user)) { - if (!item.CanDownload(user)) - { - throw new ArgumentException("Item does not support downloading"); - } + throw new ArgumentException("Item does not support downloading"); } - else + } + else + { + if (!item.CanDownload()) { - if (!item.CanDownload()) - { - throw new ArgumentException("Item does not support downloading"); - } + throw new ArgumentException("Item does not support downloading"); } + } - if (user is not null) - { - await LogDownloadAsync(item, user).ConfigureAwait(false); - } + if (user is not null) + { + await LogDownloadAsync(item, user).ConfigureAwait(false); + } - // 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>(); - } + // Quotes are valid in linux. They'll possibly cause issues here. + var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User); + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true); + } - 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); + /// <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 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()); - } + if (item is Episode || (item is IItemByName && item is not MusicArtist)) + { + return new QueryResult<BaseItemDto>(); + } - 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 user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User); + + var program = item as IHasProgramAttributes; + bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer; + bool? isSeries = item is Series || (program is not null && program.IsSeries); + + var includeItemTypes = new List<BaseItemKind>(); + if (isMovie.Value) + { + includeItemTypes.Add(BaseItemKind.Movie); + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - 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 +832,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 +843,147 @@ 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(); + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); - return metadataOptions.Length == 0 + return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); - } + } - private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + private bool IsImageFetcherEnabledByDefault(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, "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); + 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); } - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); + 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); + } - if (metadataOptions.Length == 0) - { - return true; - } + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); - return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); + if (metadataOptions.Length == 0) + { + return true; } + + return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); } } diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 1c2394055..b012ff42e 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 5228e0bab..267ba4afb 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 3d8b9e0ca..b9772a069 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 8115c3585..da24616ff 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 3cf079362..e1145481f 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 f4fb5f44a..435457af6 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/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs deleted file mode 100644 index a28556476..000000000 --- a/Jellyfin.Api/Controllers/NotificationsController.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Collections.Generic; -using Jellyfin.Api.Constants; -using MediaBrowser.Controller.Notifications; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Notifications; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Jellyfin.Api.Controllers -{ - /// <summary> - /// The notification controller. - /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class NotificationsController : BaseJellyfinApiController - { - private readonly INotificationManager _notificationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="NotificationsController" /> class. - /// </summary> - /// <param name="notificationManager">The notification manager.</param> - public NotificationsController(INotificationManager notificationManager) - { - _notificationManager = notificationManager; - } - - /// <summary> - /// Gets notification types. - /// </summary> - /// <response code="200">All notification types returned.</response> - /// <returns>An <cref see="OkResult"/> containing a list of all notification types.</returns> - [HttpGet("Types")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<NotificationTypeInfo> GetNotificationTypes() - { - return _notificationManager.GetNotificationTypes(); - } - - /// <summary> - /// Gets notification services. - /// </summary> - /// <response code="200">All notification services returned.</response> - /// <returns>An <cref see="OkResult"/> containing a list of all notification services.</returns> - [HttpGet("Services")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<NameIdPair> GetNotificationServices() - { - return _notificationManager.GetNotificationServices(); - } - } -} diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 10f967dcd..0ba5e995f 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 09f7281ec..b4c6f490a 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 e0c565da1..c6dbea5e2 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; - } + _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) + /// <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) { - 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; + ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>(); } - /// <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 ??= createPlaylistRequest?.UserId ?? default; + userId = RequestHelpers.GetUserId(User, userId); + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { - await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); - return NoContent(); - } + Name = name ?? createPlaylistRequest?.Name, + ItemIdList = ids, + UserId = userId.Value, + MediaType = mediaType ?? createPlaylistRequest?.MediaType + }).ConfigureAwait(false); - /// <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(); - } + return result; + } - /// <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(); - } + /// <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(); + } - /// <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> + /// 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 user = userId.Equals(default) - ? null - : _userManager.GetUserById(userId); + /// <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 items = playlist.GetManageableItems().ToArray(); + /// <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(); + } - var count = items.Length; + var user = userId.Equals(default) + ? null + : _userManager.GetUserById(userId); - if (startIndex.HasValue) - { - items = items.Skip(startIndex.Value).ToArray(); - } + var items = playlist.GetManageableItems().ToArray(); - if (limit.HasValue) - { - items = items.Take(limit.Value).ToArray(); - } + var count = items.Length; - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value).ToArray(); + } - var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); + if (limit.HasValue) + { + items = items.Take(limit.Value).ToArray(); + } - for (int index = 0; index < dtos.Count; index++) - { - dtos[index].PlaylistItemId = items[index].Item1.Id; - } + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var result = new QueryResult<BaseItemDto>( - startIndex, - count, - dtos); + var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); - return result; + for (int index = 0; index < dtos.Count; index++) + { + dtos[index].PlaylistItemId = items[index].Item1.Id; } + + var result = new QueryResult<BaseItemDto>( + startIndex, + count, + dtos); + + return result; } } diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 58f9b7d35..8ad553bcb 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 b8a09990a..4726cf066 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.OrderBy(p => p.Manifest.Status).FirstOrDefault(); + 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 6dbcdae22..d7e54b5b6 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -13,126 +13,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 da9e8cf90..5c77db240 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 832e14505..c8fa11ac6 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 3b7719f37..f638c31c3 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -4,6 +4,7 @@ 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 +21,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 25f930135..e93456de6 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 eec5779e6..aab390d1f 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -10,141 +10,140 @@ 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() - { - // 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 - }; - } + /// <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(); - /// <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 1288fb512..f434f60f5 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 c3ce1868e..e38421338 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -30,522 +30,521 @@ 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; } + + yield return fontFile; } - else - { - _logger.LogWarning("The path of fallback font folder has not been set"); - encodingOptions.EnableFallbackFont = false; - } } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + } - /// <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) + /// <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 (!string.IsNullOrEmpty(fallbackFontPath)) { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var fallbackFontPath = encodingOptions.FallbackFontPath; + var fontFile = _fileSystem.GetFiles(fallbackFontPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var fileSize = fontFile?.Length; - if (!string.IsNullOrEmpty(fallbackFontPath)) + if (fontFile is not null && fileSize is not null && fileSize > 0) { - 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.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"); - } + _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); + return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); } else { - _logger.LogWarning("The path of fallback font folder has not been set"); - encodingOptions.EnableFallbackFont = false; + _logger.LogWarning("The selected font is null or empty"); } - - // returning HTTP 204 will break the SubtitlesOctopus - return Ok(); } + 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 1cf528153..5b808f257 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 99347246e..23abba7dc 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 2d594293e..4ab705f40 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -20,204 +20,203 @@ 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> + /// <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 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> + /// <returns>No content. Server restarted.</returns> + [HttpPost("Restart")] + [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + 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> + /// <returns>No content. Server shut down.</returns> + [HttpPost("Shutdown")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + 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> + /// <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() + { + 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> + /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns> + [HttpGet("Endpoint")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + 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> + /// <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 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 e7c5a7125..d7304cf42 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 53a839e43..b5b640620 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 7f4f4d077..7d23281f2 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 d77126a35..12d033ae6 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -5,7 +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; @@ -20,197 +19,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 +182,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 568224a42..b0973b8a1 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -25,564 +25,576 @@ 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; + /// <summary> - /// User controller. + /// Initializes a new instance of the <see cref="UserController"/> class. /// </summary> - [Route("Users")] - public class UserController : BaseJellyfinApiController + /// <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) { - 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) - { - _userManager = userManager; - _sessionManager = sessionManager; - _networkManager = networkManager; - _deviceManager = deviceManager; - _authContext = authContext; - _config = config; - _logger = logger; - _quickConnectManager = quickConnectManager; - } - - /// <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) - { - var users = Get(isHidden, isDisabled, false, false); - return Ok(users); - } - - /// <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() - { - // If the startup wizard hasn't been completed then just return all users - if (!_config.Configuration.IsStartupWizardCompleted) - { - return Ok(Get(false, false, false, false)); - } + _userManager = userManager; + _sessionManager = sessionManager; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + _config = config; + _logger = logger; + _quickConnectManager = quickConnectManager; + } - return Ok(Get(false, false, true, true)); + /// <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> + /// 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() + { + // If the startup wizard hasn't been completed then just return all users + if (!_config.Configuration.IsStartupWizardCompleted) + { + return Ok(Get(false, false, false, false)); } - /// <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) + 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 user = _userManager.GetUserById(userId); + return NotFound("User not found"); + } - if (user is null) - { - return NotFound("User not found"); - } + var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); + return result; + } - 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) + { + return NotFound(); } - /// <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); - await _sessionManager.RevokeUserTokens(user.Id, null).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); - - if (user is null) - { - return NotFound("User not found"); - } + await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); + await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); + return NoContent(); + } - AuthenticateUserByName request = new AuthenticateUserByName - { - Username = user.Username, - Pw = pw - }; - return await AuthenticateUserByName(request).ConfigureAwait(false); + /// <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); + + if (user is null) + { + return NotFound("User not found"); } - /// <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) + AuthenticateUserByName request = new AuthenticateUserByName { - var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + Username = user.Username, + Pw = pw + }; + return await AuthenticateUserByName(request).ConfigureAwait(false); + } - 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) + /// <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 { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } + 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> - /// 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) + /// <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 { - try - { - return _quickConnectManager.GetAuthorizedRequest(request.Secret); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } + return _quickConnectManager.GetAuthorizedRequest(request.Secret); + } + 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(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 (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); - } + /// <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."); + } - var user = _userManager.GetUserById(userId); + var user = _userManager.GetUserById(userId); - if (user is null) - { - return NotFound("User not found"); - } + if (user is null) + { + return NotFound("User not found"); + } - if (request.ResetPassword) - { - await _userManager.ResetPassword(user).ConfigureAwait(false); - } - else + if (request.ResetPassword) + { + await _userManager.ResetPassword(user).ConfigureAwait(false); + } + else + { + if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId)) { - if (!User.IsInRole(UserRoles.Administrator)) + 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) { - 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."); - } + return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); } + } - await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); - - var currentToken = User.GetToken(); + await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false); - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } + var currentToken = User.GetToken(); - return NoContent(); - } - - /// <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)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); - } + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); + } - var user = _userManager.GetUserById(userId); + return NoContent(); + } - if (user is null) - { - return NotFound("User not found"); - } + /// <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] + [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)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); + } - if (request.ResetPassword) - { - await _userManager.ResetEasyPassword(user).ConfigureAwait(false); - } - else - { - await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false); - } + var user = _userManager.GetUserById(userId); - 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(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 (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); - } + if (user is null) + { + return NotFound("User not found"); + } - var user = _userManager.GetUserById(userId); + if (request.ResetPassword) + { + await _userManager.ResetEasyPassword(user).ConfigureAwait(false); + } + else + { + await _userManager.ChangeEasyPassword(user, request.NewPw ?? string.Empty, request.NewPassword ?? string.Empty).ConfigureAwait(false); + } - if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) - { - await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); - } + return NoContent(); + } - await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); - - 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 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."); - } - } + /// <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(); + } - // If disabling - if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) - { - return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); - } + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User 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."); - } + if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + { + await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + } - var currentToken = User.GetToken(); - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } + await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); - await _userManager.UpdatePolicyAsync(userId, newPolicy).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 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 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 configuration update not allowed"); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); } - - await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); - - return NoContent(); } - /// <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 disabling + if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) { - var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); + return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); + } - // no need to authenticate password for new user - if (request.Password is not null) + // If disabling + if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) + { + if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } - var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); - - return result; + var currentToken = User.GetToken(); + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); } - /// <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) + await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); + + return NoContent(); + } + + /// <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)) { - var ip = HttpContext.GetNormalizedRemoteIp(); - var isLocal = HttpContext.IsLocal() - || _networkManager.IsInLocalNetwork(ip); + return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); + } - if (isLocal) - { - _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); - } + await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); - var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); + return NoContent(); + } - return result; + /// <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); + + // no need to authenticate password for new user + if (request.Password is not null) + { + await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); } - /// <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 = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); + + return result; + } + + /// <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); + + if (isLocal) { - var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); - return result; + _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); } - /// <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() - { - var userId = User.GetUserId(); - if (userId.Equals(default)) - { - return BadRequest(); - } + var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); - var user = _userManager.GetUserById(userId); - if (user is null) - { - return BadRequest(); - } + 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(); + } - return _userManager.GetUserDto(user); + var user = _userManager.GetUserById(userId); + if (user is null) + { + return BadRequest(); } - private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + return _userManager.GetUserDto(user); + } + + private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + { + var users = _userManager.Users; + + if (isDisabled.HasValue) { - var users = _userManager.Users; + users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); + } - if (isDisabled.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); - } + if (isHidden.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); + } - if (isHidden.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); - } + if (filterByDevice) + { + var deviceId = User.GetDeviceId(); - if (filterByDevice) + if (!string.IsNullOrWhiteSpace(deviceId)) { - var deviceId = User.GetDeviceId(); - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); - } + users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); } + } - if (filterByNetwork) + if (filterByNetwork) + { + if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) { - 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 cd21c5f6f..2c4fe9186 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 3aeb444df..838b43234 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 bb3162614..23b9ba46f 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 64d8fb498..c0ec646ed 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 cd85ba221..74370db50 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 6b3e78d4d..d2e8eb378 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 9e784f7c4..2d7a56d91 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 new file mode 100644 index 000000000..96b29b1cb --- /dev/null +++ b/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs @@ -0,0 +1,20 @@ +using Jellyfin.Extensions.Json; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Camel Case Json Profile Formatter. +/// </summary> +public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter +{ + /// <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)); + } +} diff --git a/Jellyfin.Api/Formatters/CssOutputFormatter.cs b/Jellyfin.Api/Formatters/CssOutputFormatter.cs new file mode 100644 index 000000000..0a3891138 --- /dev/null +++ b/Jellyfin.Api/Formatters/CssOutputFormatter.cs @@ -0,0 +1,35 @@ +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Css output formatter. +/// </summary> +public class CssOutputFormatter : TextOutputFormatter +{ + /// <summary> + /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. + /// </summary> + public CssOutputFormatter() + { + SupportedMediaTypes.Add("text/css"); + + 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); + } +} diff --git a/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs new file mode 100644 index 000000000..b5b575278 --- /dev/null +++ b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs @@ -0,0 +1,23 @@ +using System.Net.Mime; +using Jellyfin.Extensions.Json; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Pascal Case Json Profile Formatter. +/// </summary> +public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter +{ + /// <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)); + } +} diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs new file mode 100644 index 000000000..d5dea0f09 --- /dev/null +++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs @@ -0,0 +1,32 @@ +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Xml output formatter. +/// </summary> +public class XmlOutputFormatter : TextOutputFormatter +{ + /// <summary> + /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. + /// </summary> + public XmlOutputFormatter() + { + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); + + 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); + } +} diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index be410ebcd..2b18c389d 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 010b181f7..245239233 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; @@ -24,722 +25,724 @@ 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 + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && 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)) - { - builder.Append(flacWaPlaylist); - } - - // Provide SDR HEVC entrance for backward compatibility. - if (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 = _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"; - } - } - - // 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 requestedVideoProfiles = state.GetRequestedProfiles("hevc"); + if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) { - var playlistCodecsField = new StringBuilder(); - AppendPlaylistCodecsField(playlistCodecsField, state); + // Force HEVC Main Profile and disable video stream copy. + state.OutputVideoCodec = "hevc"; + var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); + sdrVideoUrl += "&AllowVideoStreamCopy=false"; - // Force the video level to 5.0. - var originalLevel = state.VideoStream.Level; - state.VideoStream.Level = 150; - var newPlaylistCodecsField = new StringBuilder(); - AppendPlaylistCodecsField(newPlaylistCodecsField, state); + var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + var sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; + var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; - // Restore the video level. - state.VideoStream.Level = originalLevel; - var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); - builder.Append(newPlaylist); + 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())) - { - var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0; - - // By default, vary by just 200k - var variation = GetBitrateVariation(totalBitrate); - - var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + // 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); - 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); - playlistBuilder.Append(Environment.NewLine); - playlistBuilder.AppendLine(url); - builder.Append(playlistBuilder); + AppendPlaylistCodecsField(playlistBuilder, state); - return playlistBuilder; + AppendPlaylistResolutionField(playlistBuilder, state); + + AppendPlaylistFramerateField(playlistBuilder, state); + + 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 && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) { - 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 (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) - { - builder.Append(",VIDEO-RANGE=SDR"); - } - - if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) - { - builder.Append(",VIDEO-RANGE=PQ"); - } - } - else + if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) { - // 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 (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) + { + 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 (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - return parsedLevel; + levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; + 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 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) + { + 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)) { - 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 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; } - private int GetBitrateVariation(int bitrate) + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) { - // By default, vary by just 50k - var variation = 50000; - - 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) - { - variation = 100000; - } - - return variation; + string profile = GetOutputVideoCodecProfile(state, "h264"); + return HlsCodecStringHelpers.GetH264String(profile, level); } - private string ReplaceVideoBitrate(string url, int oldValue, int newValue) + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { - return url.Replace( - "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), - "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), - StringComparison.OrdinalIgnoreCase); + string profile = GetOutputVideoCodecProfile(state, "hevc"); + return HlsCodecStringHelpers.GetH265String(profile, level); } - private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + return string.Empty; + } + + private int GetBitrateVariation(int bitrate) + { + // By default, vary by just 50k + var variation = 50000; + + if (bitrate >= 10000000) { - string profileStr = codec + "-profile="; - return url.Replace( - profileStr + oldValue, - profileStr + newValue, - StringComparison.OrdinalIgnoreCase); + variation = 2000000; } - - private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + else if (bitrate >= 5000000) { - var oldPlaylist = playlist.ToString(); - return oldPlaylist.Replace( - oldValue.ToString(), - newValue.ToString(), - StringComparison.Ordinal); + variation = 1500000; } - - private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist) + else if (bitrate >= 3000000) { - if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) - { - return string.Empty; - } + variation = 1000000; + } + else if (bitrate >= 2000000) + { + 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 5bdd3fe2e..0f0a70c69 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 cbe82979b..995488397 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -2,182 +2,181 @@ 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 const string OPUS = "opus"; + + /// <summary> + /// Gets a MP3 codec string. /// </summary> - public static class HlsCodecStringHelpers + /// <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; - } + 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) + /// <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)) { - 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(); + result.Append(".40.5"); } - - /// <summary> - /// Gets an AC-3 codec string. - /// </summary> - /// <returns>AC-3 codec string.</returns> - public static string GetAC3String() + else { - return AC3; + // Default to LC if profile is invalid + result.Append(".40.2"); } - /// <summary> - /// Gets an E-AC-3 codec string. - /// </summary> - /// <returns>E-AC-3 codec string.</returns> - public static string GetEAC3String() + 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 EAC3; + result.Append(".6400"); } - - /// <summary> - /// Gets an FLAC codec string. - /// </summary> - /// <returns>FLAC codec string.</returns> - public static string GetFLACString() + else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase)) { - return FLAC; + result.Append(".4D40"); } - - /// <summary> - /// Gets an ALAC codec string. - /// </summary> - /// <returns>ALAC codec string.</returns> - public static string GetALACString() + else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase)) { - return ALAC; + result.Append(".42E0"); } - - /// <summary> - /// Gets an OPUS codec string. - /// </summary> - /// <returns>OPUS codec string.</returns> - public static string GetOPUSString() + else { - return OPUS; + // Default to constrained baseline if profile is invalid + result.Append(".4240"); } - /// <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) + string levelHex = level.ToString("X2", CultureInfo.InvariantCulture); + result.Append(levelHex); + + 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)) { - 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(); + result.Append(".2.4"); } - - /// <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) + else { - // 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 main if profile is invalid + result.Append(".1.4"); } + + result.Append(".L") + .Append(level) + .Append(".B0"); + + return result.ToString(); } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index 671107c1f..2155e305d 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 e0245fe4d..5910d8073 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 dfeeea2b0..d7b1c9f8b 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 035d84513..57098edba 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 d4fc9c020..9b5a14c4d 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -22,761 +22,760 @@ 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('.'); + state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); - state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); - state.OutputAudioCodec = streamingRequest.AudioCodec; + state.OutputAudioCodec = streamingRequest.AudioCodec; - state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); + state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); - if (state.VideoRequest is not null) - { - state.OutputVideoCodec = state.Request.VideoCodec; - state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + if (state.VideoRequest is not null) + { + state.OutputVideoCodec = state.Request.VideoCodec; + state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - encodingHelper.TryStreamCopy(state); + encodingHelper.TryStreamCopy(state); - if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) + { + 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) { - 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 + { + 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) { - 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) || + string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - return ext; + return ".ts"; } - // Try to infer based on the desired video codec - if (state.IsVideoRequest) + if (string.Equals(videoCodec, "theora", 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"; - } - - if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) - { - return ".asf"; - } + 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; + } - if (!(@static.HasValue && @static.Value)) + 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)) + { + 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 77dd51860..f25a71869 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, new Logger<TranscodingThrottler>(new LoggerFactory()), _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 b5444138f..6a0a4706b 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.3"> + <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 new file mode 100644 index 000000000..7bcc328aa --- /dev/null +++ b/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.Networking.Configuration; +using MediaBrowser.Controller.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; + +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> + /// 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; + + 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)) + { + _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); + return; + } + + await _next(httpContext).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs new file mode 100644 index 000000000..060c14f89 --- /dev/null +++ b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,150 @@ +using System; +using System.IO; +using System.Net.Mime; +using System.Net.Sockets; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +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> + /// 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) + { + _next = next; + _logger = logger; + _configuration = serverConfigurationManager; + _hostEnvironment = hostEnvironment; + } + + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <returns>Task.</returns> + public async Task Invoke(HttpContext context) + { + try + { + await _next(context).ConfigureAwait(false); + } + 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); + + 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); + } + + 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); + } + } + + private static Exception GetActualException(Exception ex) + { + if (ex is AggregateException agg) + { + 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 ex; + } + + private static int GetStatusCode(Exception ex) + { + switch (ex) + { + 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 new file mode 100644 index 000000000..f45b6b5c0 --- /dev/null +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using Microsoft.AspNetCore.Http; + +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> + /// 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) + { + _next = 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()) + { + // Running locally. + await _next(httpContext).ConfigureAwait(false); + return; + } + + var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; + + if (!networkManager.HasRemoteAccess(remoteIp)) + { + return; + } + + await _next(httpContext).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs new file mode 100644 index 000000000..9c2194faf --- /dev/null +++ b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs @@ -0,0 +1,49 @@ +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; + +/// <summary> +/// Validates the LAN host IP based on application configuration. +/// </summary> +public class LanFilteringMiddleware +{ + private readonly RequestDelegate _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) + { + _next = 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) + { + await _next(httpContext).ConfigureAwait(false); + return; + } + + var host = httpContext.GetNormalizedRemoteIp(); + if (!networkManager.IsInLocalNetwork(host)) + { + return; + } + + await _next(httpContext).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs new file mode 100644 index 000000000..17d8997d5 --- /dev/null +++ b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +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> + /// 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) + { + _next = next; + _logger = 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)) + { + 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); + } +} diff --git a/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs new file mode 100644 index 000000000..cb4169e99 --- /dev/null +++ b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// URL decodes the querystring before binding. +/// </summary> +public class QueryStringDecodingMiddleware +{ + private readonly RequestDelegate _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) + { + _next = 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) + { + httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature)); + } + + await _next(httpContext).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs new file mode 100644 index 000000000..db3917743 --- /dev/null +++ b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using System.Globalization; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Logging; + +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> + /// 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(); + + 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)) + { + _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; + }); + + // 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 new file mode 100644 index 000000000..8bf626035 --- /dev/null +++ b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +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> + /// 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) + { + _next = next; + _logger = 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)) + { + _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); + httpContext.Response.Redirect("web/robots.txt"); + return; + } + + await _next(httpContext).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs new file mode 100644 index 000000000..dcb234658 --- /dev/null +++ b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs @@ -0,0 +1,50 @@ +using System; +using System.Net.Mime; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Model.Globalization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Shows a custom message during server startup. +/// </summary> +public class ServerStartupMessageMiddleware +{ + private readonly RequestDelegate _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) + { + _next = 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)) + { + 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); + } +} diff --git a/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs new file mode 100644 index 000000000..f75d0d24e --- /dev/null +++ b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Extensions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Defines the <see cref="UrlDecodeQueryFeature"/>. +/// </summary> +public class UrlDecodeQueryFeature : IQueryFeature +{ + private IQueryCollection? _store; + + /// <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) + { + Query = feature.Query; + } + + /// <summary> + /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. + /// </summary> + public IQueryCollection Query + { + get + { + return _store ?? QueryCollection.Empty; + } + + set + { + // 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; + } + + // 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; + } + + var pairs = new Dictionary<string, StringValues>(); + foreach (var pair in key.SpanSplit('&')) + { + 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); + } + } + + _store = new QueryCollection(pairs); + } + } +} diff --git a/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs new file mode 100644 index 000000000..009fb6269 --- /dev/null +++ b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Handles WebSocket requests. +/// </summary> +public class WebSocketHandlerMiddleware +{ + private readonly RequestDelegate _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) + { + _next = 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) + { + await _next(httpContext).ConfigureAwait(false); + return; + } + + await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs index 75e47a71b..a34fd01d5 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 e1cb725f3..87a30773e 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 d2e78ac88..a2e139ca1 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 da0addd0e..43ffdaefd 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 4257ba0e2..cb9a82955 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 44509a9c0..168247fd5 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 3b827ec12..5a48345eb 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 ec4a0d1a1..e7bcd6c53 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 92be15b8a..c438e5a97 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 418c11c2d..c54205bfa 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 358434434..6401522f6 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 7de44aa65..78efacd94 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 20f45196d..125a6746e 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 f93638898..b34e0bba5 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 852315b92..5bbaea669 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 ab68d5223..16d3f65c9 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 8b26ec317..94ffc5238 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 c78ed51f7..225c7c7bc 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 fbd4985f9..a4d33f3b9 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 e293c461c..75222ed01 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; } + required public 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> + required public 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 411e4c550..5e7dd689e 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 e7501bd9f..2dbaece5e 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 704542326..99b3f7020 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 c6bd5e56e..0ef1867cd 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 9060500c8..480ddab09 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 9c4e377cd..b577c4ea6 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 0761b2085..1fba32c5b 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 fa62472e1..b88be33b2 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 a5f012245..402707819 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 4027ba41a..0e7be24c4 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 e4c973548..f473bbcef 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 3791fadbe..4f1abb1ff 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 7a4be091b..1cd3d0132 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 1fce1d20a..b75272d3f 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 f8b0212b6..389d6006d 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 cce2a89d4..60c529d4a 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 be0595798..3c903ea6b 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 479c44084..e7613911e 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 4c30b7be4..8ccd831bd 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 ed97b8d6a..89ba511af 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 3af25f3e3..220d147f2 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 441d7be36..32a3bb444 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 f59a93f13..b5223af5d 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 c4ac06856..f13395057 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 844388cd9..e0edaf5e0 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 7fd4a49be..f52bd7f46 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 2b187f443..c2c2fba04 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 d9c193016..d8be75ef1 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 226a584e1..2c7234272 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 b9af0be7f..f461417e9 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 b937679fc..40e665039 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 e748fc3e0..387d1ea77 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 0e427f4a4..a67e3958c 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 31208264f..70c18a98a 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 1c88d3628..6b6d9682b 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] + required public 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 b31c6539c..a0631fd07 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] + required public string EnteredUsername { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs b/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs index 62780e23c..79b8a5d63 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] + required public string Pin { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs index 9493c08c2..245002f80 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 0a173ea1a..80b6203bc 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 8288dbbc4..5347fcc9a 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 84b6b0958..314b6a324 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 288e03fcf..4a5e0ecd4 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 94df23e56..a9df2d671 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 void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) - { - SendData(true); - e.Task.TaskProgress -= OnTaskProgress; - } + private async void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) + { + e.Task.TaskProgress -= OnTaskProgress; + await SendData(true).ConfigureAwait(false); + } - private void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) - { - SendData(true); - e.Argument.TaskProgress += OnTaskProgress; - } + private async void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) + { + await SendData(true).ConfigureAwait(false); + e.Argument.TaskProgress += OnTaskProgress; + } - private void OnTaskProgress(object? sender, GenericEventArgs<double> e) - { - SendData(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 d996ac69f..0d8bf205c 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 b7ba30180..82abfb831 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 eb59e70f3..606e1b542 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -508,6 +508,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 +526,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 7d5200874..40280b95e 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/PreferenceKind.cs b/Jellyfin.Data/Enums/PreferenceKind.cs index a54d789af..d2b412e45 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/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 7fe6466d4..1bc5d8bf9 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.3"> + <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/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj index 975d1c8ce..4cff5927f 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.3"> + <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 5520e2f04..f406e27a6 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); } @@ -375,7 +375,7 @@ namespace Jellyfin.Networking.Manager if (MatchesPublishedServerUrl(source, isExternal, out string res, out port)) { - _logger.LogInformation("{Source}: Using BindAddress {Address}:{Port}", source, res, port); + _logger.LogDebug("{Source}: Using BindAddress {Address}:{Port}", source, res, port); return res; } } @@ -1019,8 +1019,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 +1145,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()); } } diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 9d6ca6aab..ce1c54cbb 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -15,13 +15,13 @@ namespace Jellyfin.Server.Implementations.Activity /// </summary> public class ActivityManager : IActivityManager { - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; /// <summary> /// Initializes a new instance of the <see cref="ActivityManager"/> class. /// </summary> /// <param name="provider">The Jellyfin database provider.</param> - public ActivityManager(IDbContextFactory<JellyfinDb> provider) + public ActivityManager(IDbContextFactory<JellyfinDbContext> provider) { _provider = provider; } @@ -48,18 +48,10 @@ namespace Jellyfin.Server.Implementations.Activity var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - IQueryable<ActivityLog> entries = dbContext.ActivityLogs - .OrderByDescending(entry => entry.DateCreated); - - if (query.MinDate.HasValue) - { - entries = entries.Where(entry => entry.DateCreated >= query.MinDate); - } - - if (query.HasUserId.HasValue) - { - entries = entries.Where(entry => (!entry.UserId.Equals(default)) == query.HasUserId.Value); - } + var entries = dbContext.ActivityLogs + .OrderByDescending(entry => entry.DateCreated) + .Where(entry => query.MinDate == null || entry.DateCreated >= query.MinDate) + .Where(entry => !query.HasUserId.HasValue || entry.UserId.Equals(default) != query.HasUserId.Value); return new QueryResult<ActivityLogEntry>( query.Skip, @@ -67,8 +59,16 @@ namespace Jellyfin.Server.Implementations.Activity await entries .Skip(query.Skip ?? 0) .Take(query.Limit ?? 100) - .AsAsyncEnumerable() - .Select(ConvertToOldModel) + .Select(entity => new ActivityLogEntry(entity.Name, entity.Type, entity.UserId) + { + Id = entity.Id, + Overview = entity.Overview, + ShortOverview = entity.ShortOverview, + ItemId = entity.ItemId, + Date = entity.DateCreated, + Severity = entity.LogSeverity + }) + .AsQueryable() .ToListAsync() .ConfigureAwait(false)); } diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 15ac5c668..a4b4c1959 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; @@ -23,7 +24,7 @@ namespace Jellyfin.Server.Implementations.Devices /// </summary> public class DeviceManager : IDeviceManager { - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IUserManager _userManager; private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new(); @@ -32,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Devices /// </summary> /// <param name="dbProvider">The database provider.</param> /// <param name="userManager">The user manager.</param> - public DeviceManager(IDbContextFactory<JellyfinDb> dbProvider, IUserManager userManager) + public DeviceManager(IDbContextFactory<JellyfinDbContext> dbProvider, IUserManager userManager) { _dbProvider = dbProvider; _userManager = userManager; @@ -54,7 +55,7 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - deviceOptions = await dbContext.DeviceOptions.AsQueryable().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false); + deviceOptions = await dbContext.DeviceOptions.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false); if (deviceOptions is null) { deviceOptions = new DeviceOptions(deviceId); @@ -132,22 +133,11 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var devices = dbContext.Devices.AsQueryable(); - - if (query.UserId.HasValue) - { - devices = devices.Where(device => device.UserId.Equals(query.UserId.Value)); - } - - if (query.DeviceId is not null) - { - devices = devices.Where(device => device.DeviceId == query.DeviceId); - } - - if (query.AccessToken is not null) - { - devices = devices.Where(device => device.AccessToken == query.AccessToken); - } + var devices = dbContext.Devices + .OrderBy(d => d.Id) + .Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value)) + .Where(device => query.DeviceId == null || device.DeviceId == query.DeviceId) + .Where(device => query.AccessToken == null || device.AccessToken == query.AccessToken); var count = await devices.CountAsync().ConfigureAwait(false); @@ -179,11 +169,10 @@ namespace Jellyfin.Server.Implementations.Devices /// <inheritdoc /> public async Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId, bool? supportsSync) { - IAsyncEnumerable<Device> sessions; var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - sessions = dbContext.Devices + IAsyncEnumerable<Device> sessions = dbContext.Devices .Include(d => d.User) .OrderByDescending(d => d.DateLastActivity) .ThenBy(d => d.DeviceId) @@ -197,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/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index 05c622931..bb8d4dd14 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using EFCoreSecondLevelCacheInterceptor; using MediaBrowser.Common.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Implementations.Extensions; @@ -29,13 +28,11 @@ public static class ServiceCollectionExtensions .SkipCachingResults(result => result.Value is null || (result.Value is EFTableRows rows && rows.RowsCount == 0))); - serviceCollection.AddPooledDbContextFactory<JellyfinDb>((serviceProvider, opt) => + serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) => { var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>(); - var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>(); opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}") - .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()) - .UseLoggerFactory(loggerFactory); + .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()); }); return serviceCollection; diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index bc437c5d7..390ed58b3 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.3"> + <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/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs deleted file mode 100644 index dc4f53913..000000000 --- a/Jellyfin.Server.Implementations/JellyfinDb.cs +++ /dev/null @@ -1,162 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using System.Linq; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Entities.Security; -using Jellyfin.Data.Interfaces; -using Microsoft.EntityFrameworkCore; - -namespace Jellyfin.Server.Implementations -{ - /// <inheritdoc/> - public class JellyfinDb : DbContext - { - /// <summary> - /// Initializes a new instance of the <see cref="JellyfinDb"/> class. - /// </summary> - /// <param name="options">The database context options.</param> - public JellyfinDb(DbContextOptions<JellyfinDb> options) : base(options) - { - } - - /// <summary> - /// Gets or sets the default connection string. - /// </summary> - public static string ConnectionString { get; set; } = @"Data Source=jellyfin.db"; - - public virtual DbSet<AccessSchedule> AccessSchedules { get; set; } - - public virtual DbSet<ActivityLog> ActivityLogs { get; set; } - - public virtual DbSet<ApiKey> ApiKeys { get; set; } - - public virtual DbSet<Device> Devices { get; set; } - - public virtual DbSet<DeviceOptions> DeviceOptions { get; set; } - - public virtual DbSet<DisplayPreferences> DisplayPreferences { get; set; } - - public virtual DbSet<ImageInfo> ImageInfos { get; set; } - - public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; } - - public virtual DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences { get; set; } - - public virtual DbSet<Permission> Permissions { get; set; } - - public virtual DbSet<Preference> Preferences { get; set; } - - public virtual DbSet<User> Users { get; set; } - - /*public virtual DbSet<Artwork> Artwork { get; set; } - - public virtual DbSet<Book> Books { get; set; } - - public virtual DbSet<BookMetadata> BookMetadata { get; set; } - - public virtual DbSet<Chapter> Chapters { get; set; } - - public virtual DbSet<Collection> Collections { get; set; } - - public virtual DbSet<CollectionItem> CollectionItems { get; set; } - - public virtual DbSet<Company> Companies { get; set; } - - public virtual DbSet<CompanyMetadata> CompanyMetadata { get; set; } - - public virtual DbSet<CustomItem> CustomItems { get; set; } - - public virtual DbSet<CustomItemMetadata> CustomItemMetadata { get; set; } - - public virtual DbSet<Episode> Episodes { get; set; } - - public virtual DbSet<EpisodeMetadata> EpisodeMetadata { get; set; } - - public virtual DbSet<Genre> Genres { get; set; } - - public virtual DbSet<Group> Groups { get; set; } - - public virtual DbSet<Library> Libraries { get; set; } - - public virtual DbSet<LibraryItem> LibraryItems { get; set; } - - public virtual DbSet<LibraryRoot> LibraryRoot { get; set; } - - public virtual DbSet<MediaFile> MediaFiles { get; set; } - - public virtual DbSet<MediaFileStream> MediaFileStream { get; set; } - - public virtual DbSet<Metadata> Metadata { get; set; } - - public virtual DbSet<MetadataProvider> MetadataProviders { get; set; } - - public virtual DbSet<MetadataProviderId> MetadataProviderIds { get; set; } - - public virtual DbSet<Movie> Movies { get; set; } - - public virtual DbSet<MovieMetadata> MovieMetadata { get; set; } - - public virtual DbSet<MusicAlbum> MusicAlbums { get; set; } - - public virtual DbSet<MusicAlbumMetadata> MusicAlbumMetadata { get; set; } - - public virtual DbSet<Person> People { get; set; } - - public virtual DbSet<PersonRole> PersonRoles { get; set; } - - public virtual DbSet<Photo> Photo { get; set; } - - public virtual DbSet<PhotoMetadata> PhotoMetadata { get; set; } - - public virtual DbSet<ProviderMapping> ProviderMappings { get; set; } - - public virtual DbSet<Rating> Ratings { get; set; } - - /// <summary> - /// Repository for global::Jellyfin.Data.Entities.RatingSource - This is the entity to - /// store review ratings, not age ratings. - /// </summary> - public virtual DbSet<RatingSource> RatingSources { get; set; } - - public virtual DbSet<Release> Releases { get; set; } - - public virtual DbSet<Season> Seasons { get; set; } - - public virtual DbSet<SeasonMetadata> SeasonMetadata { get; set; } - - public virtual DbSet<Series> Series { get; set; } - - public virtual DbSet<SeriesMetadata> SeriesMetadata { get; set; } - - public virtual DbSet<Track> Tracks { get; set; } - - public virtual DbSet<TrackMetadata> TrackMetadata { get; set; }*/ - - /// <inheritdoc/> - public override int SaveChanges() - { - foreach (var saveEntity in ChangeTracker.Entries() - .Where(e => e.State == EntityState.Modified) - .Select(entry => entry.Entity) - .OfType<IHasConcurrencyToken>()) - { - saveEntity.OnSavingChanges(); - } - - return base.SaveChanges(); - } - - /// <inheritdoc /> - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); - base.OnModelCreating(modelBuilder); - modelBuilder.HasDefaultSchema("jellyfin"); - - // Configuration for each entity is in it's own class inside 'ModelConfiguration'. - modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDb).Assembly); - } - } -} diff --git a/Jellyfin.Server.Implementations/JellyfinDbContext.cs b/Jellyfin.Server.Implementations/JellyfinDbContext.cs new file mode 100644 index 000000000..0d91707e3 --- /dev/null +++ b/Jellyfin.Server.Implementations/JellyfinDbContext.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.Security; +using Jellyfin.Data.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations; + +/// <inheritdoc/> +public class JellyfinDbContext : DbContext +{ + /// <summary> + /// Initializes a new instance of the <see cref="JellyfinDbContext"/> class. + /// </summary> + /// <param name="options">The database context options.</param> + public JellyfinDbContext(DbContextOptions<JellyfinDbContext> options) : base(options) + { + } + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the access schedules. + /// </summary> + public DbSet<AccessSchedule> AccessSchedules => Set<AccessSchedule>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the activity logs. + /// </summary> + public DbSet<ActivityLog> ActivityLogs => Set<ActivityLog>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the API keys. + /// </summary> + public DbSet<ApiKey> ApiKeys => Set<ApiKey>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the devices. + /// </summary> + public DbSet<Device> Devices => Set<Device>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the device options. + /// </summary> + public DbSet<DeviceOptions> DeviceOptions => Set<DeviceOptions>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the display preferences. + /// </summary> + public DbSet<DisplayPreferences> DisplayPreferences => Set<DisplayPreferences>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the image infos. + /// </summary> + public DbSet<ImageInfo> ImageInfos => Set<ImageInfo>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the item display preferences. + /// </summary> + public DbSet<ItemDisplayPreferences> ItemDisplayPreferences => Set<ItemDisplayPreferences>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the custom item display preferences. + /// </summary> + public DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences => Set<CustomItemDisplayPreferences>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the permissions. + /// </summary> + public DbSet<Permission> Permissions => Set<Permission>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the preferences. + /// </summary> + public DbSet<Preference> Preferences => Set<Preference>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the users. + /// </summary> + public DbSet<User> Users => Set<User>(); + + /*public DbSet<Artwork> Artwork => Set<Artwork>(); + + public DbSet<Book> Books => Set<Book>(); + + public DbSet<BookMetadata> BookMetadata => Set<BookMetadata>(); + + public DbSet<Chapter> Chapters => Set<Chapter>(); + + public DbSet<Collection> Collections => Set<Collection>(); + + public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>(); + + public DbSet<Company> Companies => Set<Company>(); + + public DbSet<CompanyMetadata> CompanyMetadata => Set<CompanyMetadata>(); + + public DbSet<CustomItem> CustomItems => Set<CustomItem>(); + + public DbSet<CustomItemMetadata> CustomItemMetadata => Set<CustomItemMetadata>(); + + public DbSet<Episode> Episodes => Set<Episode>(); + + public DbSet<EpisodeMetadata> EpisodeMetadata => Set<EpisodeMetadata>(); + + public DbSet<Genre> Genres => Set<Genre>(); + + public DbSet<Group> Groups => Set<Groups>(); + + public DbSet<Library> Libraries => Set<Library>(); + + public DbSet<LibraryItem> LibraryItems => Set<LibraryItems>(); + + public DbSet<LibraryRoot> LibraryRoot => Set<LibraryRoot>(); + + public DbSet<MediaFile> MediaFiles => Set<MediaFiles>(); + + public DbSet<MediaFileStream> MediaFileStream => Set<MediaFileStream>(); + + public DbSet<Metadata> Metadata => Set<Metadata>(); + + public DbSet<MetadataProvider> MetadataProviders => Set<MetadataProvider>(); + + public DbSet<MetadataProviderId> MetadataProviderIds => Set<MetadataProviderId>(); + + public DbSet<Movie> Movies => Set<Movie>(); + + public DbSet<MovieMetadata> MovieMetadata => Set<MovieMetadata>(); + + public DbSet<MusicAlbum> MusicAlbums => Set<MusicAlbum>(); + + public DbSet<MusicAlbumMetadata> MusicAlbumMetadata => Set<MusicAlbumMetadata>(); + + public DbSet<Person> People => Set<Person>(); + + public DbSet<PersonRole> PersonRoles => Set<PersonRole>(); + + public DbSet<Photo> Photo => Set<Photo>(); + + public DbSet<PhotoMetadata> PhotoMetadata => Set<PhotoMetadata>(); + + public DbSet<ProviderMapping> ProviderMappings => Set<ProviderMapping>(); + + public DbSet<Rating> Ratings => Set<Rating>(); + + /// <summary> + /// Repository for global::Jellyfin.Data.Entities.RatingSource - This is the entity to + /// store review ratings, not age ratings. + /// </summary> + public DbSet<RatingSource> RatingSources => Set<RatingSource>(); + + public DbSet<Release> Releases => Set<Release>(); + + public DbSet<Season> Seasons => Set<Season>(); + + public DbSet<SeasonMetadata> SeasonMetadata => Set<SeasonMetadata>(); + + public DbSet<Series> Series => Set<Series>(); + + public DbSet<SeriesMetadata> SeriesMetadata => Set<SeriesMetadata(); + + public DbSet<Track> Tracks => Set<Track>(); + + public DbSet<TrackMetadata> TrackMetadata => Set<TrackMetadata>();*/ + + /// <inheritdoc/> + public override int SaveChanges() + { + foreach (var saveEntity in ChangeTracker.Entries() + .Where(e => e.State == EntityState.Modified) + .Select(entry => entry.Entity) + .OfType<IHasConcurrencyToken>()) + { + saveEntity.OnSavingChanges(); + } + + return base.SaveChanges(); + } + + /// <inheritdoc /> + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); + base.OnModelCreating(modelBuilder); + + // Configuration for each entity is in it's own class inside 'ModelConfiguration'. + modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDbContext).Assembly); + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs index 98a83b745..4be6c2faa 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200514181226_AddActivityLog")] partial class AddActivityLog { diff --git a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs index 6342ce9cf..f3254734a 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200613202153_AddUsers")] partial class AddUsers { diff --git a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs index d44707d06..12d6faa8f 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200728005145_AddDisplayPreferences")] partial class AddDisplayPreferences { diff --git a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs index 2234f9d5f..f1cc20805 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200905220533_FixDisplayPreferencesIndex")] partial class FixDisplayPreferencesIndex { diff --git a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs index e5c326a32..f134d363c 100644 --- a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20201004171403_AddMaxActiveSessions")] partial class AddMaxActiveSessions { diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs index 10663d065..ec65205d4 100644 --- a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20201204223655_AddCustomDisplayPreferences")] partial class AddCustomDisplayPreferences { diff --git a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs index 869676824..45dad6be6 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20210320181425_AddIndexesAndCollations")] partial class AddIndexesAndCollations { diff --git a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs index d332d19f2..eff84b457 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20210407110544_NullableCustomPrefValue")] partial class NullableCustomPrefValue { diff --git a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs index 7e9566e2e..ad7c2dd2c 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20210814002109_AddDevices")] partial class AddDevices { diff --git a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs index 03e3f3c92..f9497a3b6 100644 --- a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20221022080052_AddIndexActivityLogsDateCreated")] partial class AddIndexActivityLogsDateCreated { diff --git a/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs b/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs index 72a4a8c3b..940cf7c5d 100644 --- a/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs +++ b/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs @@ -4,17 +4,17 @@ using Microsoft.EntityFrameworkCore.Design; namespace Jellyfin.Server.Implementations.Migrations { /// <summary> - /// The design time factory for <see cref="JellyfinDb"/>. + /// The design time factory for <see cref="JellyfinDbContext"/>. /// This is only used for the creation of migrations and not during runtime. /// </summary> - internal class DesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDb> + internal class DesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDbContext> { - public JellyfinDb CreateDbContext(string[] args) + public JellyfinDbContext CreateDbContext(string[] args) { - var optionsBuilder = new DbContextOptionsBuilder<JellyfinDb>(); + var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>(); optionsBuilder.UseSqlite("Data Source=jellyfin.db"); - return new JellyfinDb(optionsBuilder.Options); + return new JellyfinDbContext(optionsBuilder.Options); } } } diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index 2dd7b094a..dd5f7f012 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] partial class JellyfinDbModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs index 810e57807..b2dfe60a1 100644 --- a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs +++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs @@ -10,13 +10,13 @@ namespace Jellyfin.Server.Implementations.Security /// <inheritdoc /> public class AuthenticationManager : IAuthenticationManager { - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; /// <summary> /// Initializes a new instance of the <see cref="AuthenticationManager"/> class. /// </summary> /// <param name="dbProvider">The database provider.</param> - public AuthenticationManager(IDbContextFactory<JellyfinDb> dbProvider) + public AuthenticationManager(IDbContextFactory<JellyfinDbContext> dbProvider) { _dbProvider = dbProvider; } @@ -40,7 +40,6 @@ namespace Jellyfin.Server.Implementations.Security await using (dbContext.ConfigureAwait(false)) { return await dbContext.ApiKeys - .AsAsyncEnumerable() .Select(key => new AuthenticationInfo { AppName = key.Name, @@ -60,7 +59,6 @@ namespace Jellyfin.Server.Implementations.Security await using (dbContext.ConfigureAwait(false)) { var key = await dbContext.ApiKeys - .AsQueryable() .Where(apiKey => apiKey.AccessToken == accessToken) .FirstOrDefaultAsync() .ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index ec5742bab..63d3e8a04 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -16,12 +16,12 @@ namespace Jellyfin.Server.Implementations.Security { public class AuthorizationContext : IAuthorizationContext { - private readonly IDbContextFactory<JellyfinDb> _jellyfinDbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _jellyfinDbProvider; private readonly IUserManager _userManager; private readonly IServerApplicationHost _serverApplicationHost; public AuthorizationContext( - IDbContextFactory<JellyfinDb> jellyfinDb, + IDbContextFactory<JellyfinDbContext> jellyfinDb, IUserManager userManager, IServerApplicationHost serverApplicationHost) { diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index fddad1c4f..bfae81e4c 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -15,13 +15,13 @@ namespace Jellyfin.Server.Implementations.Users /// </summary> public class DisplayPreferencesManager : IDisplayPreferencesManager { - private readonly JellyfinDb _dbContext; + private readonly JellyfinDbContext _dbContext; /// <summary> /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class. /// </summary> /// <param name="dbContextFactory">The database context factory.</param> - public DisplayPreferencesManager(IDbContextFactory<JellyfinDb> dbContextFactory) + public DisplayPreferencesManager(IDbContextFactory<JellyfinDbContext> dbContextFactory) { _dbContext = dbContextFactory.CreateDbContext(); } @@ -62,7 +62,6 @@ namespace Jellyfin.Server.Implementations.Users public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client) { return _dbContext.ItemDisplayPreferences - .AsQueryable() .Where(prefs => prefs.UserId.Equals(userId) && !prefs.ItemId.Equals(default) && string.Equals(prefs.Client, client)) .ToList(); } @@ -71,7 +70,6 @@ namespace Jellyfin.Server.Implementations.Users public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client) { return _dbContext.CustomItemDisplayPreferences - .AsQueryable() .Where(prefs => prefs.UserId.Equals(userId) && prefs.ItemId.Equals(itemId) && string.Equals(prefs.Client, client)) @@ -82,7 +80,6 @@ namespace Jellyfin.Server.Implementations.Users public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences) { var existingPrefs = _dbContext.CustomItemDisplayPreferences - .AsQueryable() .Where(prefs => prefs.UserId.Equals(userId) && prefs.ItemId.Equals(itemId) && string.Equals(prefs.Client, client)); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 19ac007b9..c4756433e 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Users /// </summary> public class UserManager : IUserManager { - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IEventManager _eventManager; private readonly ICryptoProvider _cryptoProvider; private readonly INetworkManager _networkManager; @@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users /// <param name="imageProcessor">The image processor.</param> /// <param name="logger">The logger.</param> public UserManager( - IDbContextFactory<JellyfinDb> dbProvider, + IDbContextFactory<JellyfinDbContext> dbProvider, IEventManager eventManager, ICryptoProvider cryptoProvider, INetworkManager networkManager, @@ -85,6 +85,7 @@ namespace Jellyfin.Server.Implementations.Users _users = new ConcurrentDictionary<Guid, User>(); using var dbContext = _dbProvider.CreateDbContext(); foreach (var user in dbContext.Users + .AsSplitQuery() .Include(user => user.Permissions) .Include(user => user.Preferences) .Include(user => user.AccessSchedules) @@ -143,7 +144,6 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { if (await dbContext.Users - .AsQueryable() .AnyAsync(u => u.Username == newName && !u.Id.Equals(user.Id)) .ConfigureAwait(false)) { @@ -172,7 +172,7 @@ namespace Jellyfin.Server.Implementations.Users } } - internal async Task<User> CreateUserInternalAsync(string name, JellyfinDb dbContext) + internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext) { // TODO: Remove after user item data is migrated. var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false) @@ -369,8 +369,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 +686,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 +699,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 +740,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 @@ -886,7 +890,7 @@ namespace Jellyfin.Server.Implementations.Users await UpdateUserAsync(user).ConfigureAwait(false); } - private async Task UpdateUserInternalAsync(JellyfinDb dbContext, User user) + private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Update(user); _users[user.Id] = user; diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index d70b8f3ab..40cd5a044 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -107,7 +107,7 @@ namespace Jellyfin.Server yield return typeof(CoreAppHost).Assembly; // Jellyfin.Server.Implementations - yield return typeof(JellyfinDb).Assembly; + yield return typeof(JellyfinDbContext).Assembly; } /// <inheritdoc /> diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index e29167747..463ca7321 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; +using Jellyfin.Api.Middleware; using Jellyfin.Networking.Configuration; -using Jellyfin.Server.Middleware; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.OpenApi.Models; diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index f74152405..9867c9e47 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -5,28 +5,24 @@ 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; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using Jellyfin.Networking.Configuration; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; -using Jellyfin.Server.Formatters; using MediaBrowser.Common.Net; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; @@ -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/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs new file mode 100644 index 000000000..58d3e1b2d --- /dev/null +++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Net; +using Jellyfin.Server.Helpers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Extensions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Extensions; + +/// <summary> +/// Extensions for configuring the web host builder. +/// </summary> +public static class WebHostBuilderExtensions +{ + /// <summary> + /// Configure the web host builder. + /// </summary> + /// <param name="builder">The builder to configure.</param> + /// <param name="appHost">The application host.</param> + /// <param name="startupConfig">The application configuration.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="logger">The logger.</param> + /// <returns>The configured web host builder.</returns> + public static IWebHostBuilder ConfigureWebHostBuilder( + this IWebHostBuilder builder, + CoreAppHost appHost, + IConfiguration startupConfig, + IApplicationPaths appPaths, + ILogger logger) + { + return builder + .UseKestrel((builderContext, options) => + { + var addresses = appHost.NetManager.GetAllBindInterfaces(); + + bool flagged = false; + foreach (IPObject netAdd in addresses) + { + logger.LogInformation("Kestrel listening on {Address}", IPAddress.IPv6Any.Equals(netAdd.Address) ? "All Addresses" : netAdd); + options.Listen(netAdd.Address, appHost.HttpPort); + if (appHost.ListenWithHttps) + { + options.Listen( + netAdd.Address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps(appHost.Certificate)); + } + else if (builderContext.HostingEnvironment.IsDevelopment()) + { + try + { + options.Listen( + netAdd.Address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps()); + } + catch (InvalidOperationException) + { + if (!flagged) + { + logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted"); + flagged = true; + } + } + } + } + + // Bind to unix socket (only on unix systems) + if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) + { + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + + // Workaround for https://github.com/aspnet/AspNetCore/issues/14134 + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + + options.ListenUnixSocket(socketPath); + logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); + } + }) + .UseStartup(_ => new Startup(appHost)); + } +} diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs index 4af670e9a..fb9f6d0a6 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/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs deleted file mode 100644 index ea8c5ecdb..000000000 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Jellyfin.Extensions.Json; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Net.Http.Headers; - -namespace Jellyfin.Server.Formatters -{ - /// <summary> - /// Camel Case Json Profile Formatter. - /// </summary> - public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter - { - /// <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)); - } - } -} diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs deleted file mode 100644 index fdaa48f84..000000000 --- a/Jellyfin.Server/Formatters/CssOutputFormatter.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Formatters; - -namespace Jellyfin.Server.Formatters -{ - /// <summary> - /// Css output formatter. - /// </summary> - public class CssOutputFormatter : TextOutputFormatter - { - /// <summary> - /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. - /// </summary> - public CssOutputFormatter() - { - SupportedMediaTypes.Add("text/css"); - - 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); - } - } -} diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs deleted file mode 100644 index 03ca7dda7..000000000 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Net.Mime; -using Jellyfin.Extensions.Json; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Net.Http.Headers; - -namespace Jellyfin.Server.Formatters -{ - /// <summary> - /// Pascal Case Json Profile Formatter. - /// </summary> - public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter - { - /// <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)); - } - } -} diff --git a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs deleted file mode 100644 index 156368d69..000000000 --- a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Net.Mime; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Formatters; - -namespace Jellyfin.Server.Formatters -{ - /// <summary> - /// Xml output formatter. - /// </summary> - public class XmlOutputFormatter : TextOutputFormatter - { - /// <summary> - /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. - /// </summary> - public XmlOutputFormatter() - { - SupportedMediaTypes.Clear(); - SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); - - 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); - } - } -} diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs new file mode 100644 index 000000000..fda6e5465 --- /dev/null +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -0,0 +1,303 @@ +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; +using Emby.Server.Implementations; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Extensions; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Serilog; +using SQLitePCL; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Jellyfin.Server.Helpers; + +/// <summary> +/// A class containing helper methods for server startup. +/// </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% + /// for everything else the + /// <a href="https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html">XDG approach</a> + /// is followed. + /// </summary> + /// <param name="options">The <see cref="StartupOptions" /> for this instance.</param> + /// <returns><see cref="ServerApplicationPaths" />.</returns> + public static ServerApplicationPaths CreateApplicationPaths(StartupOptions options) + { + // 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"); + + 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()) + { + // UNIX: $XDG_CONFIG_HOME + configDir = Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "jellyfin"); + } + } + + var cacheDir = options.CacheDir ?? Environment.GetEnvironmentVariable("JELLYFIN_CACHE_DIR"); + if (cacheDir is null) + { + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + { + cacheDir = Path.Join(dataDir, "cache"); + } + else + { + cacheDir = Path.Join(GetXdgCacheHome(), "jellyfin"); + } + } + + var webDir = options.WebDir ?? Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR"); + if (webDir is null) + { + webDir = Path.Join(AppContext.BaseDirectory, "jellyfin-web"); + } + + var logDir = options.LogDir ?? Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR"); + if (logDir is null) + { + logDir = Path.Join(dataDir, "log"); + } + + // Normalize paths. Only possible with GetFullPath for now - https://github.com/dotnet/runtime/issues/2162 + dataDir = Path.GetFullPath(dataDir); + logDir = Path.GetFullPath(logDir); + configDir = Path.GetFullPath(configDir); + cacheDir = Path.GetFullPath(cacheDir); + webDir = Path.GetFullPath(webDir); + + // Ensure the main folders exist before we continue + try + { + Directory.CreateDirectory(dataDir); + Directory.CreateDirectory(logDir); + Directory.CreateDirectory(configDir); + Directory.CreateDirectory(cacheDir); + } + catch (IOException ex) + { + Console.Error.WriteLine("Error whilst attempting to create folder"); + Console.Error.WriteLine(ex.ToString()); + Environment.Exit(1); + } + + 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> + /// <param name="startupConfig">The startup config.</param> + /// <param name="appPaths">The application paths.</param> + /// <returns>The path for Kestrel to bind to.</returns> + public static string GetUnixSocketPath(IConfiguration startupConfig, IApplicationPaths appPaths) + { + var socketPath = startupConfig.GetUnixSocketPath(); + + if (string.IsNullOrEmpty(socketPath)) + { + const string SocketFile = "jellyfin.sock"; + + var xdgRuntimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + if (xdgRuntimeDir is null) + { + // Fall back to config dir + socketPath = Path.Join(appPaths.ConfigurationDirectoryPath, SocketFile); + } + else + { + socketPath = Path.Join(xdgRuntimeDir, SocketFile); + } + } + + return socketPath; + } + + /// <summary> + /// Sets the unix file permissions for Kestrel's socket file. + /// </summary> + /// <param name="startupConfig">The startup config.</param> + /// <param name="socketPath">The socket path.</param> + /// <param name="logger">The logger.</param> + [UnsupportedOSPlatform("windows")] + public static void SetUnixSocketPermissions(IConfiguration startupConfig, string socketPath, ILogger logger) + { + var socketPerms = startupConfig.GetUnixSocketPermissions(); + + if (!string.IsNullOrEmpty(socketPerms)) + { + File.SetUnixFileMode(socketPath, (UnixFileMode)Convert.ToInt32(socketPerms, 8)); + logger.LogInformation("Kestrel unix socket permissions set to {SocketPerms}", socketPerms); + } + } + + /// <summary> + /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist + /// already. + /// </summary> + /// <param name="appPaths">The application paths.</param> + /// <returns>A task representing the creation of the configuration file, or a completed task if the file already exists.</returns> + public static async Task InitLoggingConfigFile(IApplicationPaths appPaths) + { + // Do nothing if the config file already exists + string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, Program.LoggingConfigFileDefault); + if (File.Exists(configPath)) + { + return; + } + + // Get a stream of the resource contents + // NOTE: The .csproj name is used instead of the assembly name in the resource path + const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json"; + Stream resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) + ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); + await using (resource.ConfigureAwait(false)) + { + Stream dst = new FileStream(configPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await using (dst.ConfigureAwait(false)) + { + // Copy the resource contents to the expected file path for the config file + await resource.CopyToAsync(dst).ConfigureAwait(false); + } + } + } + + /// <summary> + /// Initialize Serilog using configuration and fall back to defaults on failure. + /// </summary> + /// <param name="configuration">The configuration object.</param> + /// <param name="appPaths">The application paths.</param> + public static void InitializeLoggingFramework(IConfiguration configuration, IApplicationPaths appPaths) + { + try + { + // Serilog.Log is used by SerilogLoggerFactory when no logger is specified + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .Enrich.FromLogContext() + .Enrich.WithThreadId() + .CreateLogger(); + } + catch (Exception ex) + { + Log.Logger = new LoggerConfiguration() + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", + formatProvider: CultureInfo.InvariantCulture) + .WriteTo.Async(x => x.File( + Path.Combine(appPaths.LogDirectoryPath, "log_.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", + formatProvider: CultureInfo.InvariantCulture, + encoding: Encoding.UTF8)) + .Enrich.FromLogContext() + .Enrich.WithThreadId() + .CreateLogger(); + + Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); + } + } + + /// <summary> + /// Call static initialization methods for the application. + /// </summary> + public static void PerformStaticInitialization() + { + // Make sure we have all the code pages we can get + // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // Increase the max http request limit + // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others. + ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); + + // Disable the "Expect: 100-Continue" header by default + // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c + ServicePointManager.Expect100Continue = false; + + Batteries_V2.Init(); + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 829f294ce..146de3ae1 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.3"> + <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/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs deleted file mode 100644 index 6ee5bf38a..000000000 --- a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Threading.Tasks; -using Jellyfin.Networking.Configuration; -using MediaBrowser.Controller.Configuration; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; - -namespace Jellyfin.Server.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> - /// 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; - - 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)) - { - _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); - return; - } - - await _next(httpContext).ConfigureAwait(false); - } - } -} diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs deleted file mode 100644 index 91dbce19a..000000000 --- a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System; -using System.IO; -using System.Net.Mime; -using System.Net.Sockets; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.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> - /// 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) - { - _next = next; - _logger = logger; - _configuration = serverConfigurationManager; - _hostEnvironment = hostEnvironment; - } - - /// <summary> - /// Invoke request. - /// </summary> - /// <param name="context">Request context.</param> - /// <returns>Task.</returns> - public async Task Invoke(HttpContext context) - { - try - { - await _next(context).ConfigureAwait(false); - } - 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); - - 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); - } - - 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); - } - } - - private static Exception GetActualException(Exception ex) - { - if (ex is AggregateException agg) - { - 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 ex; - } - - private static int GetStatusCode(Exception ex) - { - switch (ex) - { - 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.Server/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs deleted file mode 100644 index 0afcd61a0..000000000 --- a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Server.Middleware -{ - /// <summary> - /// Validates the IP of requests coming from local networks wrt. remote access. - /// </summary> - public class IpBasedAccessValidationMiddleware - { - private readonly RequestDelegate _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) - { - _next = 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()) - { - // Running locally. - await _next(httpContext).ConfigureAwait(false); - return; - } - - var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - - if (!networkManager.HasRemoteAccess(remoteIp)) - { - return; - } - - await _next(httpContext).ConfigureAwait(false); - } - } -} diff --git a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs deleted file mode 100644 index 67bf24d2a..000000000 --- a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using Jellyfin.Networking.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Server.Middleware -{ - /// <summary> - /// Validates the LAN host IP based on application configuration. - /// </summary> - public class LanFilteringMiddleware - { - private readonly RequestDelegate _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) - { - _next = 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) - { - var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - - if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess) - { - return; - } - - await _next(httpContext).ConfigureAwait(false); - } - } -} diff --git a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs deleted file mode 100644 index b214299df..000000000 --- a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.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> - /// 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) - { - _next = next; - _logger = 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)) - { - 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); - } - } -} diff --git a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs deleted file mode 100644 index 24807ce38..000000000 --- a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; - -namespace Jellyfin.Server.Middleware -{ - /// <summary> - /// URL decodes the querystring before binding. - /// </summary> - public class QueryStringDecodingMiddleware - { - private readonly RequestDelegate _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) - { - _next = 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) - { - httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature)); - } - - await _next(httpContext).ConfigureAwait(false); - } - } -} diff --git a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs deleted file mode 100644 index 531897cd4..000000000 --- a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.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> - /// 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(); - - 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)) - { - _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; - }); - - // Call the next delegate/middleware in the pipeline - await this._next(context).ConfigureAwait(false); - } - } -} diff --git a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs deleted file mode 100644 index fabcd2da7..000000000 --- a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.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> - /// 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) - { - _next = next; - _logger = 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)) - { - _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); - httpContext.Response.Redirect("web/robots.txt"); - return; - } - - await _next(httpContext).ConfigureAwait(false); - } - } -} diff --git a/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs b/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs deleted file mode 100644 index 2ec063392..000000000 --- a/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Net.Mime; -using System.Threading.Tasks; -using MediaBrowser.Controller; -using MediaBrowser.Model.Globalization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Server.Middleware -{ - /// <summary> - /// Shows a custom message during server startup. - /// </summary> - public class ServerStartupMessageMiddleware - { - private readonly RequestDelegate _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) - { - _next = 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)) - { - 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); - } - } -} diff --git a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs deleted file mode 100644 index 2f1d79157..000000000 --- a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Jellyfin.Extensions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Primitives; - -namespace Jellyfin.Server.Middleware -{ - /// <summary> - /// Defines the <see cref="UrlDecodeQueryFeature"/>. - /// </summary> - public class UrlDecodeQueryFeature : IQueryFeature - { - private IQueryCollection? _store; - - /// <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) - { - Query = feature.Query; - } - - /// <summary> - /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. - /// </summary> - public IQueryCollection Query - { - get - { - return _store ?? QueryCollection.Empty; - } - - set - { - // 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; - } - - // 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; - } - - var pairs = new Dictionary<string, StringValues>(); - foreach (var pair in key.SpanSplit('&')) - { - 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); - } - } - - _store = new QueryCollection(pairs); - } - } - } -} diff --git a/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs b/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs deleted file mode 100644 index b7a5d2b34..000000000 --- a/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Controller.Net; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Server.Middleware -{ - /// <summary> - /// Handles WebSocket requests. - /// </summary> - public class WebSocketHandlerMiddleware - { - private readonly RequestDelegate _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) - { - _next = 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) - { - await _next(httpContext).ConfigureAwait(false); - return; - } - - await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false); - } - } -} diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 23fb9e370..d4bf81f10 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -21,7 +21,9 @@ namespace Jellyfin.Server.Migrations /// </summary> private static readonly Type[] _preStartupMigrationTypes = { - typeof(PreStartupRoutines.CreateNetworkConfiguration) + typeof(PreStartupRoutines.CreateNetworkConfiguration), + typeof(PreStartupRoutines.MigrateMusicBrainzTimeout), + typeof(PreStartupRoutines.MigrateRatingLevels) }; /// <summary> diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs new file mode 100644 index 000000000..14b51bd4c --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs @@ -0,0 +1,89 @@ +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 serverConfigSerializer = new XmlSerializer(typeof(OldMusicBrainzConfiguration), new XmlRootAttribute("PluginConfiguration")); + using var xmlReader = XmlReader.Create(path); + var oldPluginConfiguration = serverConfigSerializer.Deserialize(xmlReader) as OldMusicBrainzConfiguration; + + 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; + + 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/PreStartupRoutines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateRatingLevels.cs new file mode 100644 index 000000000..465bbd7fe --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateRatingLevels.cs @@ -0,0 +1,86 @@ +using System; +using System.Globalization; +using System.IO; + +using Emby.Server.Implementations; +using MediaBrowser.Controller; +using Microsoft.Extensions.Logging; +using SQLitePCL.pretty; + +namespace Jellyfin.Server.Migrations.PreStartupRoutines +{ + /// <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; + + public MigrateRatingLevels(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory) + { + _applicationPaths = applicationPaths; + _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 dataPath = _applicationPaths.DataPath; + var dbPath = Path.Combine(dataPath, DbFilename); + using (var connection = SQLite3.Open( + dbPath, + ConnectionFlags.ReadWrite, + null)) + { + // Back up the database before deleting 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 levels to new schema + _logger.LogInformation("Migrating parental rating levels."); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating = 'NR'"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE InheritedParentalRatingValue = ''"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE InheritedParentalRatingValue = 0"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 1000 WHERE InheritedParentalRatingValue = 100"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 1000 WHERE InheritedParentalRatingValue = 15"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 18 WHERE InheritedParentalRatingValue = 10"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 18 WHERE InheritedParentalRatingValue = 9"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 16 WHERE InheritedParentalRatingValue = 8"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 7"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 6"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 5"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 7 WHERE InheritedParentalRatingValue = 4"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 6 WHERE InheritedParentalRatingValue = 3"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 6 WHERE InheritedParentalRatingValue = 2"); + connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 0 WHERE InheritedParentalRatingValue = 1"); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index bf66f75ff..e8a0af9f8 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -19,7 +19,7 @@ namespace Jellyfin.Server.Migrations.Routines private const string DbFilename = "activitylog.db"; private readonly ILogger<MigrateActivityLogDb> _logger; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; private readonly IServerApplicationPaths _paths; /// <summary> @@ -28,7 +28,7 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="logger">The logger.</param> /// <param name="paths">The server application paths.</param> /// <param name="provider">The database provider.</param> - public MigrateActivityLogDb(ILogger<MigrateActivityLogDb> logger, IServerApplicationPaths paths, IDbContextFactory<JellyfinDb> provider) + public MigrateActivityLogDb(ILogger<MigrateActivityLogDb> logger, IServerApplicationPaths paths, IDbContextFactory<JellyfinDbContext> provider) { _logger = logger; _provider = provider; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index bf1ea8233..09daae0ff 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -20,7 +20,7 @@ namespace Jellyfin.Server.Migrations.Routines private const string DbFilename = "authentication.db"; private readonly ILogger<MigrateAuthenticationDb> _logger; - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IServerApplicationPaths _appPaths; private readonly IUserManager _userManager; @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="userManager">The user manager.</param> public MigrateAuthenticationDb( ILogger<MigrateAuthenticationDb> logger, - IDbContextFactory<JellyfinDb> dbProvider, + IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationPaths appPaths, IUserManager userManager) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 0fad77cfe..7c4ffdbc0 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -25,7 +25,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateDisplayPreferencesDb> _logger; private readonly IServerApplicationPaths _paths; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; private readonly JsonSerializerOptions _jsonOptions; private readonly IUserManager _userManager; @@ -39,7 +39,7 @@ namespace Jellyfin.Server.Migrations.Routines public MigrateDisplayPreferencesDb( ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, - IDbContextFactory<JellyfinDb> provider, + IDbContextFactory<JellyfinDbContext> provider, IUserManager userManager) { _logger = logger; @@ -130,7 +130,7 @@ 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) diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 2dbd82e8f..9bf1e6b80 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -27,7 +27,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateUserDb> _logger; private readonly IServerApplicationPaths _paths; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; private readonly IXmlSerializer _xmlSerializer; /// <summary> @@ -40,7 +40,7 @@ namespace Jellyfin.Server.Migrations.Routines public MigrateUserDb( ILogger<MigrateUserDb> logger, IServerApplicationPaths paths, - IDbContextFactory<JellyfinDb> provider, + IDbContextFactory<JellyfinDbContext> provider, IXmlSerializer xmlSerializer) { _logger = logger; @@ -163,6 +163,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 540375dce..6e8b17a73 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -1,22 +1,18 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; -using System.Net; using System.Reflection; -using System.Runtime.Versioning; -using System.Text; using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; +using Jellyfin.Server.Extensions; +using Jellyfin.Server.Helpers; using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.IO; -using Microsoft.AspNetCore.Hosting; +using MediaBrowser.Controller; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -25,7 +21,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Extensions.Logging; -using SQLitePCL; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -46,8 +41,9 @@ namespace Jellyfin.Server /// </summary> public const string LoggingConfigFileSystem = "logging.json"; - private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource(); private static readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory(); + private static CancellationTokenSource _tokenSource = new(); + private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; @@ -92,14 +88,14 @@ namespace Jellyfin.Server private static async Task StartApp(StartupOptions options) { - var startTimestamp = Stopwatch.GetTimestamp(); + _startTimestamp = Stopwatch.GetTimestamp(); // Log all uncaught exceptions to std error static void UnhandledExceptionToConsole(object sender, UnhandledExceptionEventArgs e) => - Console.Error.WriteLine("Unhandled Exception\n" + e.ExceptionObject.ToString()); + Console.Error.WriteLine("Unhandled Exception\n" + e.ExceptionObject); AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionToConsole; - ServerApplicationPaths appPaths = CreateApplicationPaths(options); + ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options); // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath); @@ -108,13 +104,12 @@ namespace Jellyfin.Server Environment.SetEnvironmentVariable("NEOReadDebugKeys", "1"); Environment.SetEnvironmentVariable("EnableExtendedVaFormats", "1"); - await InitLoggingConfigFile(appPaths).ConfigureAwait(false); + await StartupHelpers.InitLoggingConfigFile(appPaths).ConfigureAwait(false); // Create an instance of the application configuration to use for application startup IConfiguration startupConfig = CreateAppConfiguration(options, appPaths); - // Initialize logging framework - InitializeLoggingFramework(startupConfig, appPaths); + StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths); _logger = _loggerFactory.CreateLogger("Main"); // Log uncaught exceptions to the logging instead of std error @@ -153,19 +148,19 @@ 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()) { - string? webContentPath = appPaths.WebPath; + var webContentPath = appPaths.WebPath; if (!Directory.Exists(webContentPath) || !Directory.EnumerateFiles(webContentPath).Any()) { _logger.LogError( "The server is expected to host the web client, but the provided content directory is either " + "invalid or empty: {WebContentPath}. If you do not want to host the web client with the " + "server, you may set the '--nowebclient' command line flag, or set" + - "'{ConfigKey}=false' in your config settings.", + "'{ConfigKey}=false' in your config settings", webContentPath, HostWebClientKey); Environment.ExitCode = 1; @@ -173,20 +168,36 @@ namespace Jellyfin.Server } } - PerformStaticInitialization(); + StartupHelpers.PerformStaticInitialization(); Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory); + do + { + _restartOnShutdown = false; + await StartServer(appPaths, options, startupConfig).ConfigureAwait(false); + + if (_restartOnShutdown) + { + _tokenSource = new CancellationTokenSource(); + _startTimestamp = Stopwatch.GetTimestamp(); + } + } while (_restartOnShutdown); + } + + private static async Task StartServer(IServerApplicationPaths appPaths, StartupOptions options, IConfiguration startupConfig) + { var appHost = new CoreAppHost( appPaths, _loggerFactory, options, startupConfig); + IHost? host = null; try { - var host = Host.CreateDefaultBuilder() + host = Host.CreateDefaultBuilder() .ConfigureServices(services => appHost.Init(services)) - .ConfigureWebHostDefaults(webHostBuilder => webHostBuilder.ConfigureWebHostBuilder(appHost, startupConfig, appPaths)) + .ConfigureWebHostDefaults(webHostBuilder => webHostBuilder.ConfigureWebHostBuilder(appHost, startupConfig, appPaths, _logger)) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) .UseSerilog() .Build(); @@ -203,20 +214,20 @@ namespace Jellyfin.Server if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) { - var socketPath = GetUnixSocketPath(startupConfig, appPaths); + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); - SetUnixSocketPermissions(startupConfig, socketPath); + StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); } } catch (Exception ex) when (ex is not TaskCanceledException) { - _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in network.xml and try again."); + _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in network.xml and try again"); throw; } await appHost.RunStartupTasksAsync(_tokenSource.Token).ConfigureAwait(false); - _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(startTimestamp)); + _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); // Block main thread until shutdown await Task.Delay(-1, _tokenSource.Token).ConfigureAwait(false); @@ -227,7 +238,7 @@ namespace Jellyfin.Server } catch (Exception ex) { - _logger.LogCritical(ex, "Error while starting server."); + _logger.LogCritical(ex, "Error while starting server"); } finally { @@ -236,7 +247,7 @@ namespace Jellyfin.Server { _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); // Run before disposing the application - var context = await appHost.ServiceProvider.GetRequiredService<IDbContextFactory<JellyfinDb>>().CreateDbContextAsync().ConfigureAwait(false); + var context = await appHost.ServiceProvider.GetRequiredService<IDbContextFactory<JellyfinDbContext>>().CreateDbContextAsync().ConfigureAwait(false); await using (context.ConfigureAwait(false)) { if (context.Database.IsSqlite()) @@ -247,300 +258,7 @@ namespace Jellyfin.Server } await appHost.DisposeAsync().ConfigureAwait(false); - } - - if (_restartOnShutdown) - { - StartNewInstance(options); - } - } - - /// <summary> - /// Call static initialization methods for the application. - /// </summary> - public static void PerformStaticInitialization() - { - // Make sure we have all the code pages we can get - // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - - // Increase the max http request limit - // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others. - ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); - - // Disable the "Expect: 100-Continue" header by default - // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c - ServicePointManager.Expect100Continue = false; - - Batteries_V2.Init(); - } - - /// <summary> - /// Configure the web host builder. - /// </summary> - /// <param name="builder">The builder to configure.</param> - /// <param name="appHost">The application host.</param> - /// <param name="startupConfig">The application configuration.</param> - /// <param name="appPaths">The application paths.</param> - /// <returns>The configured web host builder.</returns> - public static IWebHostBuilder ConfigureWebHostBuilder( - this IWebHostBuilder builder, - CoreAppHost appHost, - IConfiguration startupConfig, - IApplicationPaths appPaths) - { - return builder - .UseKestrel((builderContext, options) => - { - var addresses = appHost.NetManager.GetAllBindInterfaces(); - - bool flagged = false; - foreach (IPObject netAdd in addresses) - { - _logger.LogInformation("Kestrel listening on {Address}", netAdd.Address == IPAddress.IPv6Any ? "All Addresses" : netAdd); - options.Listen(netAdd.Address, appHost.HttpPort); - if (appHost.ListenWithHttps) - { - options.Listen( - netAdd.Address, - appHost.HttpsPort, - listenOptions => listenOptions.UseHttps(appHost.Certificate)); - } - else if (builderContext.HostingEnvironment.IsDevelopment()) - { - try - { - options.Listen( - netAdd.Address, - appHost.HttpsPort, - listenOptions => listenOptions.UseHttps()); - } - catch (InvalidOperationException) - { - if (!flagged) - { - _logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted."); - flagged = true; - } - } - } - } - - // Bind to unix socket (only on unix systems) - if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) - { - var socketPath = GetUnixSocketPath(startupConfig, appPaths); - - // Workaround for https://github.com/aspnet/AspNetCore/issues/14134 - if (File.Exists(socketPath)) - { - File.Delete(socketPath); - } - - options.ListenUnixSocket(socketPath); - _logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); - } - }) - .UseStartup(_ => new Startup(appHost)); - } - - /// <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% - /// for everything else the - /// <a href="https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html">XDG approach</a> - /// is followed. - /// </summary> - /// <param name="options">The <see cref="StartupOptions" /> for this instance.</param> - /// <returns><see cref="ServerApplicationPaths" />.</returns> - private 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"); - - if (string.IsNullOrEmpty(dataDir)) - { - // LocalApplicationData follows the XDG spec on unix machines - dataDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "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)) - { - configDir = Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR"); - - if (string.IsNullOrEmpty(configDir)) - { - 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 - // 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)) - { - 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"); - } - } - } - - // webDir - // IF --webdir - // ELSE IF $JELLYFIN_WEB_DIR - // ELSE <bindir>/jellyfin-web - var webDir = options.WebDir; - if (string.IsNullOrEmpty(webDir)) - { - webDir = Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR"); - - if (string.IsNullOrEmpty(webDir)) - { - // Use default location under ResourcesPath - webDir = Path.Combine(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)) - { - logDir = Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR"); - - if (string.IsNullOrEmpty(logDir)) - { - // Hang log folder off already set dataDir - logDir = Path.Combine(dataDir, "log"); - } - } - - // Normalize paths. Only possible with GetFullPath for now - https://github.com/dotnet/runtime/issues/2162 - dataDir = Path.GetFullPath(dataDir); - logDir = Path.GetFullPath(logDir); - configDir = Path.GetFullPath(configDir); - cacheDir = Path.GetFullPath(cacheDir); - webDir = Path.GetFullPath(webDir); - - // Ensure the main folders exist before we continue - try - { - Directory.CreateDirectory(dataDir); - Directory.CreateDirectory(logDir); - Directory.CreateDirectory(configDir); - Directory.CreateDirectory(cacheDir); - } - catch (IOException ex) - { - Console.Error.WriteLine("Error whilst attempting to create folder"); - Console.Error.WriteLine(ex.ToString()); - Environment.Exit(1); - } - - return new ServerApplicationPaths(dataDir, logDir, configDir, cacheDir, webDir); - } - - /// <summary> - /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist - /// already. - /// </summary> - /// <param name="appPaths">The application paths.</param> - /// <returns>A task representing the creation of the configuration file, or a completed task if the file already exists.</returns> - public static async Task InitLoggingConfigFile(IApplicationPaths appPaths) - { - // Do nothing if the config file already exists - string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, LoggingConfigFileDefault); - if (File.Exists(configPath)) - { - return; - } - - // Get a stream of the resource contents - // NOTE: The .csproj name is used instead of the assembly name in the resource path - const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json"; - Stream resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) - ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); - await using (resource.ConfigureAwait(false)) - { - Stream dst = new FileStream(configPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await using (dst.ConfigureAwait(false)) - { - // Copy the resource contents to the expected file path for the config file - await resource.CopyToAsync(dst).ConfigureAwait(false); - } + host?.Dispose(); } } @@ -578,112 +296,5 @@ namespace Jellyfin.Server .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); } - - /// <summary> - /// Initialize Serilog using configuration and fall back to defaults on failure. - /// </summary> - private static void InitializeLoggingFramework(IConfiguration configuration, IApplicationPaths appPaths) - { - try - { - // Serilog.Log is used by SerilogLoggerFactory when no logger is specified - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .Enrich.FromLogContext() - .Enrich.WithThreadId() - .CreateLogger(); - } - catch (Exception ex) - { - Log.Logger = new LoggerConfiguration() - .WriteTo.Console( - outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", - formatProvider: CultureInfo.InvariantCulture) - .WriteTo.Async(x => x.File( - Path.Combine(appPaths.LogDirectoryPath, "log_.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", - formatProvider: CultureInfo.InvariantCulture, - encoding: Encoding.UTF8)) - .Enrich.FromLogContext() - .Enrich.WithThreadId() - .CreateLogger(); - - Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); - } - } - - private static void StartNewInstance(StartupOptions options) - { - _logger.LogInformation("Starting new instance"); - - var module = options.RestartPath; - - if (string.IsNullOrWhiteSpace(module)) - { - module = Environment.GetCommandLineArgs()[0]; - } - - string commandLineArgsString; - if (options.RestartArgs is not null) - { - commandLineArgsString = options.RestartArgs; - } - else - { - commandLineArgsString = string.Join( - ' ', - Environment.GetCommandLineArgs().Skip(1).Select(NormalizeCommandLineArgument)); - } - - _logger.LogInformation("Executable: {0}", module); - _logger.LogInformation("Arguments: {0}", commandLineArgsString); - - Process.Start(module, commandLineArgsString); - } - - private static string NormalizeCommandLineArgument(string arg) - { - if (!arg.Contains(' ', StringComparison.Ordinal)) - { - return arg; - } - - return "\"" + arg + "\""; - } - - private static string GetUnixSocketPath(IConfiguration startupConfig, IApplicationPaths appPaths) - { - var socketPath = startupConfig.GetUnixSocketPath(); - - if (string.IsNullOrEmpty(socketPath)) - { - 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); - } - else - { - socketPath = Path.Join(xdgRuntimeDir, socketFile); - } - } - - return socketPath; - } - - [UnsupportedOSPlatform("windows")] - private static void SetUnixSocketPermissions(IConfiguration startupConfig, string socketPath) - { - var socketPerms = startupConfig.GetUnixSocketPermissions(); - - if (!string.IsNullOrEmpty(socketPerms)) - { - File.SetUnixFileMode(socketPath, (UnixFileMode)Convert.ToInt32(socketPerms, 8)); - _logger.LogInformation("Kestrel unix socket permissions set to {SocketPerms}", socketPerms); - } - } } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index f89f81c76..155f9fc8c 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -4,7 +4,9 @@ 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.Server.Extensions; @@ -12,7 +14,6 @@ using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Infrastructure; -using Jellyfin.Server.Middleware; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -108,7 +109,7 @@ namespace Jellyfin.Server string.Format( CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 {2}/{3}", - MediaBrowser.Common.System.OperatingSystem.Name, + Environment.OSVersion.Platform, Environment.OSVersion, _serverApplicationHost.Name, _serverApplicationHost.ApplicationVersionString)); @@ -119,7 +120,7 @@ namespace Jellyfin.Server .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); services.AddHealthChecks() - .AddCheck<DbContextFactoryHealthCheck<JellyfinDb>>(nameof(JellyfinDb)); + .AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext)); services.AddHlsPlaylistGenerator(); } diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index 0d9f379e0..c3989751c 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -64,14 +64,6 @@ namespace Jellyfin.Server public string? PackageName { get; set; } /// <inheritdoc /> - [Option("restartpath", Required = false, HelpText = "Path to restart script.")] - public string? RestartPath { get; set; } - - /// <inheritdoc /> - [Option("restartargs", Required = false, HelpText = "Arguments for restart script.")] - public string? RestartArgs { get; set; } - - /// <inheritdoc /> [Option("published-server-url", Required = false, HelpText = "Jellyfin Server URL to publish via auto discover process")] public string? PublishedServerUrl { get; set; } diff --git a/Jellyfin.sln b/Jellyfin.sln index c0d2ec068..cad23fc5e 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -21,16 +21,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing", "src\Jel EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Photos", "Emby.Photos\Emby.Photos.csproj", "{89AB4548-770D-41FD-A891-8DAFF44F452C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DvdLib", "DvdLib\DvdLib.csproj", "{713F42B5-878E-499D-A878-E4C652B1D5E8}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Server.Implementations", "Emby.Server.Implementations\Emby.Server.Implementations.csproj", "{E383961B-9356-4D5D-8233-9A1079D03055}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RSSDP", "RSSDP\RSSDP.csproj", "{21002819-C39A-4D3E-BE83-2A276A77FB1F}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Dlna", "Emby.Dlna\Emby.Dlna.csproj", "{805844AB-E92F-45E6-9D99-4F6D48D129A5}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Notifications", "Emby.Notifications\Emby.Notifications.csproj", "{2E030C33-6923-4530-9E54-FA29FA6AD1A9}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Naming", "Emby.Naming\Emby.Naming.csproj", "{E5AF7B26-2239-4CE0-B477-0AA2018EDAA2}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.MediaEncoding", "MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj", "{960295EE-4AF4-4440-A525-B4C295B01A61}" @@ -139,10 +135,6 @@ Global {89AB4548-770D-41FD-A891-8DAFF44F452C}.Debug|Any CPU.Build.0 = Debug|Any CPU {89AB4548-770D-41FD-A891-8DAFF44F452C}.Release|Any CPU.ActiveCfg = Release|Any CPU {89AB4548-770D-41FD-A891-8DAFF44F452C}.Release|Any CPU.Build.0 = Release|Any CPU - {713F42B5-878E-499D-A878-E4C652B1D5E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {713F42B5-878E-499D-A878-E4C652B1D5E8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {713F42B5-878E-499D-A878-E4C652B1D5E8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {713F42B5-878E-499D-A878-E4C652B1D5E8}.Release|Any CPU.Build.0 = Release|Any CPU {E383961B-9356-4D5D-8233-9A1079D03055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E383961B-9356-4D5D-8233-9A1079D03055}.Debug|Any CPU.Build.0 = Debug|Any CPU {E383961B-9356-4D5D-8233-9A1079D03055}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -155,10 +147,6 @@ Global {805844AB-E92F-45E6-9D99-4F6D48D129A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {805844AB-E92F-45E6-9D99-4F6D48D129A5}.Release|Any CPU.ActiveCfg = Release|Any CPU {805844AB-E92F-45E6-9D99-4F6D48D129A5}.Release|Any CPU.Build.0 = Release|Any CPU - {2E030C33-6923-4530-9E54-FA29FA6AD1A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2E030C33-6923-4530-9E54-FA29FA6AD1A9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2E030C33-6923-4530-9E54-FA29FA6AD1A9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2E030C33-6923-4530-9E54-FA29FA6AD1A9}.Release|Any CPU.Build.0 = Release|Any CPU {E5AF7B26-2239-4CE0-B477-0AA2018EDAA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E5AF7B26-2239-4CE0-B477-0AA2018EDAA2}.Debug|Any CPU.Build.0 = Debug|Any CPU {E5AF7B26-2239-4CE0-B477-0AA2018EDAA2}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Jellyfin.sln.DotSettings b/Jellyfin.sln.DotSettings new file mode 100644 index 000000000..b56741648 --- /dev/null +++ b/Jellyfin.sln.DotSettings @@ -0,0 +1,3 @@ +<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/=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/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 53683cdbd..96ee701b3 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -48,12 +48,6 @@ namespace MediaBrowser.Common bool IsShuttingDown { get; } /// <summary> - /// Gets a value indicating whether this instance can self restart. - /// </summary> - /// <value><c>true</c> if this instance can self restart; otherwise, <c>false</c>.</value> - bool CanSelfRestart { get; } - - /// <summary> /// Gets the application version. /// </summary> /// <value>The application version.</value> diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 0296974b5..3f1a098e4 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.3"> + <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/CustomHeaderNames.cs b/MediaBrowser.Common/Net/CustomHeaderNames.cs deleted file mode 100644 index 5ca9897eb..000000000 --- a/MediaBrowser.Common/Net/CustomHeaderNames.cs +++ /dev/null @@ -1,13 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Common.Net -{ - public static class CustomHeaderNames - { - // Other Headers - public const string XForwardedFor = "X-Forwarded-For"; - public const string XForwardedPort = "X-Forwarded-Port"; - public const string XForwardedProto = "X-Forwarded-Proto"; - public const string XRealIP = "X-Real-IP"; - } -} diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs index 7cf1b8aa0..ec76a43b6 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 ac3396a9f..de72d978e 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/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs index 176bcbbd5..fa92d383a 100644 --- a/MediaBrowser.Common/Plugins/IPluginManager.cs +++ b/MediaBrowser.Common/Plugins/IPluginManager.cs @@ -30,6 +30,11 @@ namespace MediaBrowser.Common.Plugins IEnumerable<Assembly> LoadAssemblies(); /// <summary> + /// Unloads all of the assemblies. + /// </summary> + void UnloadAssemblies(); + + /// <summary> /// Registers the plugin's services with the DI. /// Note: DI is not yet instantiated yet. /// </summary> diff --git a/MediaBrowser.Common/System/OperatingSystem.cs b/MediaBrowser.Common/System/OperatingSystem.cs deleted file mode 100644 index 5f673d320..000000000 --- a/MediaBrowser.Common/System/OperatingSystem.cs +++ /dev/null @@ -1,74 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Runtime.InteropServices; -using System.Threading; -using MediaBrowser.Model.System; - -namespace MediaBrowser.Common.System -{ - public static class OperatingSystem - { - // We can't use Interlocked.CompareExchange for enums - private static int _id = int.MaxValue; - - public static OperatingSystemId Id - { - get - { - if (_id == int.MaxValue) - { - Interlocked.CompareExchange(ref _id, (int)GetId(), int.MaxValue); - } - - return (OperatingSystemId)_id; - } - } - - public static string Name - { - get - { - switch (Id) - { - case OperatingSystemId.BSD: return "BSD"; - case OperatingSystemId.Linux: return "Linux"; - case OperatingSystemId.Darwin: return "macOS"; - case OperatingSystemId.Windows: return "Windows"; - default: throw new PlatformNotSupportedException($"Unknown OS {Id}"); - } - } - } - - private static OperatingSystemId GetId() - { - switch (Environment.OSVersion.Platform) - { - // On .NET Core `MacOSX` got replaced by `Unix`, this case should never be hit. - case PlatformID.MacOSX: - return OperatingSystemId.Darwin; - case PlatformID.Win32NT: - return OperatingSystemId.Windows; - case PlatformID.Unix: - default: - { - string osDescription = RuntimeInformation.OSDescription; - if (osDescription.Contains("linux", StringComparison.OrdinalIgnoreCase)) - { - return OperatingSystemId.Linux; - } - else if (osDescription.Contains("darwin", StringComparison.OrdinalIgnoreCase)) - { - return OperatingSystemId.Darwin; - } - else if (osDescription.Contains("bsd", StringComparison.OrdinalIgnoreCase)) - { - return OperatingSystemId.BSD; - } - - throw new PlatformNotSupportedException($"Can't resolve OS with description: '{osDescription}'"); - } - } - } - } -} diff --git a/MediaBrowser.Controller/Channels/IChannelManager.cs b/MediaBrowser.Controller/Channels/IChannelManager.cs index 49be897ef..8eb27888a 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/Drawing/ImageProcessingOptions.cs b/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs index 11e663301..7912c5e87 100644 --- a/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs +++ b/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs @@ -42,8 +42,6 @@ namespace MediaBrowser.Controller.Drawing public IReadOnlyCollection<ImageFormat> SupportedOutputFormats { get; set; } - public bool AddPlayedIndicator { get; set; } - public int? UnplayedCount { get; set; } public int? Blur { get; set; } @@ -111,7 +109,6 @@ namespace MediaBrowser.Controller.Drawing { return (Quality >= 90) && IsFormatSupported(originalImagePath) && - !AddPlayedIndicator && PercentPlayed.Equals(0) && !UnplayedCount.HasValue && !Blur.HasValue && diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index 89aafc84f..22453f0f7 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 08c622cde..d789033f1 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/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index f2c2007f7..a04f02bf9 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) { @@ -554,7 +554,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. @@ -893,16 +893,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 +911,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 +936,7 @@ namespace MediaBrowser.Controller.Entities builder.Append(chunk); } - if (name.Length == 0) + if (name.IsEmpty) { return string.Empty; } @@ -950,13 +950,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(); @@ -1534,12 +1534,6 @@ namespace MediaBrowser.Controller.Entities } var maxAllowedRating = user.MaxParentalAgeRating; - - if (maxAllowedRating is null) - { - return true; - } - var rating = CustomRatingForComparison; if (string.IsNullOrEmpty(rating)) @@ -1549,12 +1543,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 +1562,7 @@ namespace MediaBrowser.Controller.Entities return isAllowed; } - return value.Value <= maxAllowedRating.Value; + return !maxAllowedRating.HasValue || value.Value <= maxAllowedRating.Value; } public int? GetInheritedParentalRatingValue() @@ -1607,6 +1602,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 +1622,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 +2512,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 5ac619d8f..095b261c0 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/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index a1e531904..a51299284 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 882abc927..66210cb6c 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/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index c83149a6d..597b4cecb 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/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 993e3e18f..37b4afcf3 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. @@ -128,7 +126,7 @@ namespace MediaBrowser.Controller.Library /// <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 +137,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 01986d303..c70102167 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 7bc8fa5ab..6d2c3c3d2 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/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 46bdca302..3b6a16dee 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 978826042..f11e3c8f6 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/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 6434621c4..69c0d26b6 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.3"> + <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 e94a04a7d..5de57917e 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -38,7 +38,13 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly ISubtitleEncoder _subtitleEncoder; private readonly IConfiguration _config; private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15); - private readonly Version _minKernelVersioni915Hang = new Version(5, 18); + // i915 hang was fixed by linux 6.2 (3f882f2) + private readonly Version _minKerneli915Hang = new Version(5, 18); + 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 static readonly string[] _videoProfilesH264 = new[] { @@ -58,6 +64,31 @@ namespace MediaBrowser.Controller.MediaEncoding "Main10" }; + 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 EncodingHelper( IApplicationPaths appPaths, IMediaEncoder mediaEncoder, @@ -530,9 +561,12 @@ namespace MediaBrowser.Controller.MediaEncoding 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> @@ -546,6 +580,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")) { @@ -605,6 +645,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; @@ -787,6 +847,12 @@ namespace MediaBrowser.Controller.MediaEncoding args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias)); filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); } + else + { + // libplacebo wants an explicitly set vulkan filter device. + args.Append(GetVulkanDeviceArgs(0, null, VaapiAlias, VulkanAlias)); + filterDevArgs = GetFilterHwDeviceArgs(VulkanAlias); + } } else { @@ -928,8 +994,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 @@ -1124,7 +1200,7 @@ 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)) @@ -1336,7 +1412,7 @@ namespace MediaBrowser.Controller.MediaEncoding // which will reduce overhead in performance intensive tasks such as 4k transcoding and tonemapping. var intelLowPowerHwEncoding = false; - // Workaround for linux 5.18+ i915 hang at cost of performance. + // Workaround for linux 5.18 to 6.1.3 i915 hang at cost of performance. // https://github.com/intel/media-driver/issues/1456 var enableWaFori915Hang = false; @@ -1355,18 +1431,25 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) { - if (OperatingSystem.IsLinux() && Environment.OSVersion.Version >= _minKernelVersioni915Hang) + if (OperatingSystem.IsLinux()) { - var vidDecoder = GetHardwareVideoDecoder(state, encodingOptions) ?? string.Empty; - var isIntelDecoder = vidDecoder.Contains("qsv", StringComparison.OrdinalIgnoreCase) - || vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); - var doOclTonemap = _mediaEncoder.SupportsHwaccel("qsv") - && IsVaapiSupported(state) - && IsOpenclFullSupported() - && !IsVaapiVppTonemapAvailable(state, encodingOptions) - && IsHwTonemapAvailable(state, encodingOptions); + var ver = Environment.OSVersion.Version; + var isFixedKernel60 = ver.Major == 6 && ver.Minor == 0 && ver >= _minFixedKernel60i915Hang; + var isUnaffectedKernel = ver < _minKerneli915Hang || ver > _maxKerneli915Hang; - enableWaFori915Hang = isIntelDecoder && doOclTonemap; + if (!(isUnaffectedKernel || isFixedKernel60)) + { + var vidDecoder = GetHardwareVideoDecoder(state, encodingOptions) ?? string.Empty; + var isIntelDecoder = vidDecoder.Contains("qsv", StringComparison.OrdinalIgnoreCase) + || vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var doOclTonemap = _mediaEncoder.SupportsHwaccel("qsv") + && IsVaapiSupported(state) + && IsOpenclFullSupported() + && !IsVaapiVppTonemapAvailable(state, encodingOptions) + && IsHwTonemapAvailable(state, encodingOptions); + + enableWaFori915Hang = isIntelDecoder && doOclTonemap; + } } if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) @@ -1711,7 +1794,7 @@ 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); } @@ -1890,8 +1973,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) { @@ -2034,14 +2116,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; } @@ -2049,7 +2137,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.Min(outputScaleFactor / inputScaleFactor, 1); if (bitrate <= 500000) { @@ -2191,87 +2281,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) - { - // 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 (inputChannels > 0) { - // 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> @@ -2429,6 +2480,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> @@ -2794,6 +2869,13 @@ namespace MediaBrowser.Controller.MediaEncoding { return "deinterlace_qsv=mode=2"; } + else if (hwDeintSuffix.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + return string.Format( + CultureInfo.InvariantCulture, + "yadif_videotoolbox={0}:-1:0", + doubleRateDeint ? "1" : "0"); + } return string.Empty; } @@ -2939,8 +3021,8 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasGraphicalSubs) { - // [0:s]scale=expr - var subSwScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // [0:s]scale=s=1280x720 + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); } @@ -3126,9 +3208,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = isSwDecoder - ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) - : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); } @@ -3265,7 +3345,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"); } @@ -3328,9 +3408,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = isSwDecoder - ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) - : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); } @@ -3510,7 +3588,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"); } @@ -3582,9 +3660,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = isSwDecoder - ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) - : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=endall:shortest=1:repeatlast=0"); } @@ -3670,6 +3746,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); } @@ -3716,7 +3799,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"); } @@ -3793,9 +3876,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = isSwDecoder - ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) - : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); } @@ -3947,6 +4028,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); } @@ -3988,7 +4076,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"); } @@ -4054,9 +4142,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = isSwDecoder - ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) - : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); @@ -4128,7 +4214,9 @@ namespace MediaBrowser.Controller.MediaEncoding // sw => hw if (doVkTonemap) { - mainFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + mainFilters.Add("hwupload_vaapi"); + mainFilters.Add("hwmap=derive_device=vulkan"); + mainFilters.Add("format=vulkan"); } } else if (isVaapiDecoder) @@ -4158,6 +4246,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // map from vaapi to vulkan via vaapi-vulkan interop (Vega/gfx9+). mainFilters.Add("hwmap=derive_device=vulkan"); + mainFilters.Add("format=vulkan"); } // vk tonemap @@ -4234,12 +4323,14 @@ namespace MediaBrowser.Controller.MediaEncoding subFilters.Add(subTextSubtitlesFilter); } - subFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + // prefer vaapi hwupload to vulkan hwupload, + // Mesa RADV does not support a dedicated transfer queue. + subFilters.Add("hwupload_vaapi"); + subFilters.Add("hwmap=derive_device=vulkan"); + subFilters.Add("format=vulkan"); overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0"); - - // explicitly sync using libplacebo. - overlayFilters.Add("libplacebo=format=nv12:upscaler=none:downscaler=none"); + overlayFilters.Add("scale_vulkan=format=nv12"); // OUTPUT vaapi(nv12/bgra) surface(vram) // reverse-mapping via vaapi-vulkan interop. @@ -4251,9 +4342,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = isSwDecoder - ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) - : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); @@ -4340,6 +4429,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); } @@ -4428,9 +4524,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subSwScaleFilter = isSwDecoder - ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) - : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subSwScaleFilter); overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); @@ -4445,6 +4539,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> @@ -4486,6 +4649,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); @@ -4646,7 +4813,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 @@ -4787,8 +4954,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) { @@ -4814,14 +4991,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 @@ -4841,7 +5020,8 @@ 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); + 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); } else { @@ -4856,7 +5036,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); } } @@ -4865,9 +5046,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) @@ -5671,7 +5854,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); @@ -5766,6 +5951,11 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); } + if (!string.IsNullOrEmpty(state.OutputAudioCodec)) + { + audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state)); + } + if (!string.Equals(state.OutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)) { // opus only supports specific sampling rates @@ -5785,6 +5975,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 179cabc84..a6b541660 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -250,8 +250,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; } @@ -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 fe8e9063e..f830b9f29 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> @@ -194,6 +202,20 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="path">The to the .vob files.</param> /// <param name="titleNumber">The title number to start with.</param> /// <returns>A playlist.</returns> - IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber); + 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 d8475f12a..3b34af4e9 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 fc9ea37d1..0524999c7 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -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/Notifications/INotificationManager.cs b/MediaBrowser.Controller/Notifications/INotificationManager.cs deleted file mode 100644 index 7caba1097..000000000 --- a/MediaBrowser.Controller/Notifications/INotificationManager.cs +++ /dev/null @@ -1,43 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Notifications; - -namespace MediaBrowser.Controller.Notifications -{ - public interface INotificationManager - { - /// <summary> - /// Sends the notification. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task SendNotification(NotificationRequest request, CancellationToken cancellationToken); - - Task SendNotification(NotificationRequest request, BaseItem? relatedItem, CancellationToken cancellationToken); - - /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="services">The services.</param> - /// <param name="notificationTypeFactories">The notification type factories.</param> - void AddParts(IEnumerable<INotificationService> services, IEnumerable<INotificationTypeFactory> notificationTypeFactories); - - /// <summary> - /// Gets the notification types. - /// </summary> - /// <returns>IEnumerable{NotificationTypeInfo}.</returns> - List<NotificationTypeInfo> GetNotificationTypes(); - - /// <summary> - /// Gets the notification services. - /// </summary> - /// <returns>IEnumerable{NotificationServiceInfo}.</returns> - IEnumerable<NameIdPair> GetNotificationServices(); - } -} diff --git a/MediaBrowser.Controller/Notifications/INotificationService.cs b/MediaBrowser.Controller/Notifications/INotificationService.cs deleted file mode 100644 index 535c08795..000000000 --- a/MediaBrowser.Controller/Notifications/INotificationService.cs +++ /dev/null @@ -1,34 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Entities; - -namespace MediaBrowser.Controller.Notifications -{ - public interface INotificationService - { - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - string Name { get; } - - /// <summary> - /// Sends the notification. - /// </summary> - /// <param name="request">The request.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task SendNotification(UserNotification request, CancellationToken cancellationToken); - - /// <summary> - /// Determines whether [is enabled for user] [the specified user identifier]. - /// </summary> - /// <param name="user">The user.</param> - /// <returns><c>true</c> if [is enabled for user] [the specified user identifier]; otherwise, <c>false</c>.</returns> - bool IsEnabledForUser(User user); - } -} diff --git a/MediaBrowser.Controller/Notifications/INotificationTypeFactory.cs b/MediaBrowser.Controller/Notifications/INotificationTypeFactory.cs deleted file mode 100644 index 52a3e120b..000000000 --- a/MediaBrowser.Controller/Notifications/INotificationTypeFactory.cs +++ /dev/null @@ -1,16 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using MediaBrowser.Model.Notifications; - -namespace MediaBrowser.Controller.Notifications -{ - public interface INotificationTypeFactory - { - /// <summary> - /// Gets the notification types. - /// </summary> - /// <returns>IEnumerable{NotificationTypeInfo}.</returns> - IEnumerable<NotificationTypeInfo> GetNotificationTypes(); - } -} diff --git a/MediaBrowser.Controller/Notifications/UserNotification.cs b/MediaBrowser.Controller/Notifications/UserNotification.cs deleted file mode 100644 index 4be0e09ae..000000000 --- a/MediaBrowser.Controller/Notifications/UserNotification.cs +++ /dev/null @@ -1,25 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using Jellyfin.Data.Entities; -using MediaBrowser.Model.Notifications; - -namespace MediaBrowser.Controller.Notifications -{ - public class UserNotification - { - public string Name { get; set; } - - public string Description { get; set; } - - public string Url { get; set; } - - public NotificationLevel Level { get; set; } - - public DateTime Date { get; set; } - - public User User { get; set; } - } -} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 24f7b5cd3..2c52b2b45 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/Providers/EpisodeInfo.cs b/MediaBrowser.Controller/Providers/EpisodeInfo.cs index b59a03738..c4ad352a3 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/MetadataRefreshOptions.cs b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs index 8a3709462..9e91a8bcd 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 b38ee1146..c8b29aa1f 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/Subtitles/ISubtitleManager.cs b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs index 52aa44024..b86e48243 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -20,12 +18,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.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index de3987b1e..71cdea529 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.3"> + <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 1030cf055..c8912807e 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -169,12 +169,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; diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index db177ff76..80091bf5a 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; @@ -301,10 +302,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; diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs index 7e026b42e..ea520b1d6 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs @@ -1,83 +1,123 @@ -#pragma warning disable CS1591 - using System; +using System.IO; using System.Linq; using BDInfo.IO; using MediaBrowser.Model.IO; -namespace MediaBrowser.MediaEncoding.BdInfo +namespace MediaBrowser.MediaEncoding.BdInfo; + +/// <summary> +/// Class BdInfoDirectoryInfo. +/// </summary> +public class BdInfoDirectoryInfo : IDirectoryInfo { - public class BdInfoDirectoryInfo : IDirectoryInfo - { - private readonly IFileSystem _fileSystem; + private readonly IFileSystem _fileSystem; - private readonly FileSystemMetadata _impl; + private readonly FileSystemMetadata _impl; - public BdInfoDirectoryInfo(IFileSystem fileSystem, string path) - { - _fileSystem = fileSystem; - _impl = _fileSystem.GetDirectoryInfo(path); - } + /// <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; - } + private BdInfoDirectoryInfo(IFileSystem fileSystem, FileSystemMetadata impl) + { + _fileSystem = fileSystem; + _impl = impl; + } - public string Name => _impl.Name; + /// <summary> + /// Gets the name. + /// </summary> + public string Name => _impl.Name; - public string FullName => _impl.FullName; + /// <summary> + /// Gets the full name. + /// </summary> + public string FullName => _impl.FullName; - public IDirectoryInfo? Parent + /// <summary> + /// Gets the parent directory information. + /// </summary> + public IDirectoryInfo? Parent + { + get { - get + var parentFolder = Path.GetDirectoryName(_impl.FullName); + if (parentFolder is not null) { - var parentFolder = System.IO.Path.GetDirectoryName(_impl.FullName); - if (parentFolder is not null) - { - return new BdInfoDirectoryInfo(_fileSystem, parentFolder); - } - - return null; + return new BdInfoDirectoryInfo(_fileSystem, parentFolder); } - } - public IDirectoryInfo[] GetDirectories() - { - return Array.ConvertAll( - _fileSystem.GetDirectories(_impl.FullName).ToArray(), - x => new BdInfoDirectoryInfo(_fileSystem, x)); + return null; } + } - public IFileInfo[] GetFiles() - { - return Array.ConvertAll( - _fileSystem.GetFiles(_impl.FullName).ToArray(), - x => new BdInfoFileInfo(x)); - } + /// <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(); + } - public IFileInfo[] GetFiles(string searchPattern) - { - return Array.ConvertAll( - _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false).ToArray(), - x => new BdInfoFileInfo(x)); - } + /// <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(); + } - public IFileInfo[] GetFiles(string searchPattern, System.IO.SearchOption searchOption) - { - return Array.ConvertAll( - _fileSystem.GetFiles( - _impl.FullName, - new[] { searchPattern }, - false, - (searchOption & System.IO.SearchOption.AllDirectories) == System.IO.SearchOption.AllDirectories).ToArray(), - x => new BdInfoFileInfo(x)); - } + /// <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(); + } - public static IDirectoryInfo FromFileSystemPath(IFileSystem fs, string path) - { - return new BdInfoDirectoryInfo(fs, path); - } + /// <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) == 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 index 3e53cbf29..8ebb59c59 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -6,189 +6,182 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -namespace MediaBrowser.MediaEncoding.BdInfo +namespace MediaBrowser.MediaEncoding.BdInfo; + +/// <summary> +/// Class BdInfoExaminer. +/// </summary> +public class BdInfoExaminer : IBlurayExaminer { + private readonly IFileSystem _fileSystem; + /// <summary> - /// Class BdInfoExaminer. + /// Initializes a new instance of the <see cref="BdInfoExaminer" /> class. /// </summary> - public class BdInfoExaminer : IBlurayExaminer + /// <param name="fileSystem">The filesystem.</param> + public BdInfoExaminer(IFileSystem fileSystem) { - private readonly IFileSystem _fileSystem; + _fileSystem = fileSystem; + } - /// <summary> - /// Initializes a new instance of the <see cref="BdInfoExaminer" /> class. - /// </summary> - /// <param name="fileSystem">The filesystem.</param> - public BdInfoExaminer(IFileSystem 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)) { - _fileSystem = fileSystem; + throw new ArgumentNullException(nameof(path)); } - /// <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)); + var bdrom = new BDROM(BdInfoDirectoryInfo.FromFileSystemPath(_fileSystem, path)); - bdrom.Scan(); + bdrom.Scan(); - // Get the longest playlist - var playlist = bdrom.PlaylistFiles.Values.OrderByDescending(p => p.TotalLength).FirstOrDefault(p => p.IsValid); + // 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>() - }; + var outputStream = new BlurayDiscInfo + { + MediaStreams = Array.Empty<MediaStream>() + }; - if (playlist is null) - { - return outputStream; - } + if (playlist is null) + { + return outputStream; + } - outputStream.Chapters = playlist.Chapters.ToArray(); + outputStream.Chapters = playlist.Chapters.ToArray(); - outputStream.RunTimeTicks = TimeSpan.FromSeconds(playlist.TotalLength).Ticks; + outputStream.RunTimeTicks = TimeSpan.FromSeconds(playlist.TotalLength).Ticks; - var mediaStreams = new List<MediaStream>(); + var sortedStreams = playlist.SortedStreams; + var mediaStreams = new List<MediaStream>(sortedStreams.Count); - foreach (var stream in playlist.SortedStreams) + foreach (var stream in sortedStreams) + { + switch (stream) { - if (stream is TSVideoStream videoStream) - { + case TSVideoStream videoStream: AddVideoStream(mediaStreams, videoStream); - continue; - } - - if (stream is TSAudioStream audioStream) - { + break; + case TSAudioStream audioStream: AddAudioStream(mediaStreams, audioStream); - continue; - } - - if (stream is TSTextStream textStream) - { + break; + case TSTextStream textStream: AddSubtitleStream(mediaStreams, textStream); - continue; - } - - if (stream is TSGraphicsStream graphicsStream) - { - AddSubtitleStream(mediaStreams, graphicsStream); - } + break; + case TSGraphicsStream graphicStream: + AddSubtitleStream(mediaStreams, graphicStream); + break; } + } - outputStream.MediaStreams = mediaStreams.ToArray(); - - outputStream.PlaylistName = playlist.Name; + outputStream.MediaStreams = mediaStreams.ToArray(); - if (playlist.StreamClips is not null && playlist.StreamClips.Any()) - { - // Get the files in the playlist - outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.Name).ToArray(); - } + outputStream.PlaylistName = playlist.Name; - return outputStream; + 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(); } - /// <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; + return outputStream; + } - mediaStream.AverageFrameRate = mediaStream.RealFrameRate = frameRateEnumerator / frameRateDenominator; - } + /// <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; - streams.Add(mediaStream); + mediaStream.AverageFrameRate = mediaStream.RealFrameRate = frameRateEnumerator / frameRateDenominator; } - /// <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); + streams.Add(mediaStream); + } - if (bitrate > 0) - { - stream.BitRate = bitrate; - } + /// <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 + }; - if (audioStream.LFE > 0) - { - stream.Channels = audioStream.ChannelCount + 1; - } + var bitrate = Convert.ToInt32(audioStream.BitRate); - streams.Add(stream); + if (bitrate > 0) + { + stream.BitRate = bitrate; } - /// <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) + if (audioStream.LFE > 0) { - streams.Add(new MediaStream - { - Language = textStream.LanguageCode, - Codec = textStream.CodecShortName, - Type = MediaStreamType.Subtitle, - Index = streams.Count - }); + stream.Channels = audioStream.ChannelCount + 1; } - /// <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(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 { - streams.Add(new MediaStream - { - Language = textStream.LanguageCode, - Codec = textStream.CodecShortName, - Type = MediaStreamType.Subtitle, - Index = streams.Count - }); - } + 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 index d55688e3d..9e7a1d50a 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs @@ -1,41 +1,68 @@ -#pragma warning disable CS1591 - using System.IO; using MediaBrowser.Model.IO; -namespace MediaBrowser.MediaEncoding.BdInfo +namespace MediaBrowser.MediaEncoding.BdInfo; + +/// <summary> +/// Class BdInfoFileInfo. +/// </summary> +public class BdInfoFileInfo : BDInfo.IO.IFileInfo { - public class BdInfoFileInfo : BDInfo.IO.IFileInfo - { - private FileSystemMetadata _impl; + private FileSystemMetadata _impl; - public BdInfoFileInfo(FileSystemMetadata impl) - { - _impl = 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; + } - public string Name => _impl.Name; + /// <summary> + /// Gets the name. + /// </summary> + public string Name => _impl.Name; - public string FullName => _impl.FullName; + /// <summary> + /// Gets the full name. + /// </summary> + public string FullName => _impl.FullName; - public string Extension => _impl.Extension; + /// <summary> + /// Gets the extension. + /// </summary> + public string Extension => _impl.Extension; - public long Length => _impl.Length; + /// <summary> + /// Gets the length. + /// </summary> + public long Length => _impl.Length; - public bool IsDir => _impl.IsDirectory; + /// <summary> + /// Gets a value indicating whether this is a directory. + /// </summary> + public bool IsDir => _impl.IsDirectory; - public Stream OpenRead() - { - return new FileStream( - FullName, - FileMode.Open, - FileAccess.Read, - FileShare.Read); - } + /// <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); + } - public StreamReader OpenText() - { - return new StreamReader(OpenRead()); - } + /// <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 8479b7d50..540d50bf1 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -56,6 +56,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "libvpx", "libvpx-vp9", "aac", + "aac_at", "libfdk_aac", "ac3", "libmp3lame", @@ -106,7 +107,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[]> @@ -273,7 +277,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 +327,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); } diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs index d0ea0429b..04128c911 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 d95f894c5..d2112e5dc 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,6 +52,7 @@ 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; @@ -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,14 @@ 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()); } /// <inheritdoc /> @@ -114,16 +121,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 +354,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 +409,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 +426,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 +436,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 +550,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + /// <inheritdoc /> public Task<string> ExtractAudioImage(string path, int? imageStreamIndex, CancellationToken cancellationToken) { var mediaSource = new MediaSourceInfo @@ -556,11 +561,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 +771,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + /// <inheritdoc /> public string GetTimeParameter(long ticks) { var time = TimeSpan.FromTicks(ticks); @@ -863,82 +871,111 @@ namespace MediaBrowser.MediaEncoding.Encoder } /// <inheritdoc /> - public IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber) + public IReadOnlyList<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber) { - // min size 300 mb - const long MinPlayableSize = 314572800; - - // Try to eliminate menus and intros by skipping all files at the front of the list that are less than the minimum size - // Once we reach a file that is at least the minimum, return all subsequent ones + // 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.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 we didn't find any satisfying the min length, just take them all - if (allVobs.Count == 0) - { - _logger.LogWarning("No vobs found in dvd structure."); - return Enumerable.Empty<string>(); - } - if (titleNumber.HasValue) { - var prefix = string.Format( - CultureInfo.InvariantCulture, - titleNumber.Value >= 10 ? "VTS_{0}_" : "VTS_0{0}_", - titleNumber.Value); + 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) { - var minSizeVobs = vobs - .SkipWhile(f => f.Length < MinPlayableSize) - .ToList(); - - return minSizeVobs.Count == 0 ? vobs.Select(i => i.FullName) : minSizeVobs.Select(i => i.FullName); + return vobs.Select(i => i.FullName).ToList(); } - _logger.LogWarning("Could not determine vob file list for {Path} using DvdLib. Will scan using file sizes.", path); + _logger.LogWarning("Could not determine .vob files for title {Title} of {Path}.", titleNumber, path); } - var files = allVobs - .SkipWhile(f => f.Length < MinPlayableSize) + // 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(); - // If we didn't find any satisfying the min length, just take them all - if (files.Count == 0) + // Fall back to first title if no big title is found + if (titles.Count == 0) { - _logger.LogWarning("Vob size filter resulted in zero matches. Taking all vobs."); - files = allVobs; + titles.Add(_fileSystem.GetFileNameWithoutExtension(allVobs[0]).AsSpan().RightPart('_').ToString()); } - // Assuming they're named "vts_05_01", take all files whose second part matches that of the first file - if (files.Count > 0) - { - var parts = _fileSystem.GetFileNameWithoutExtension(files[0]).Split('_'); + // Aggregate all .vob files of the titles + return allVobs + .Where(vob => titles.Contains(_fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString())) + .Select(i => i.FullName) + .ToList(); + } - if (parts.Length == 3) - { - var title = parts[1]; + /// <inheritdoc /> + public IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path) + { + // Get all playable .m2ts files + var validPlaybackFiles = _blurayExaminer.GetDiscInfo(path).Files; - files = files.TakeWhile(f => - { - var fileParts = _fileSystem.GetFileNameWithoutExtension(f).Split('_'); + // Get all files from the BDMV/STREAMING directory + var directoryFiles = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM")); - return fileParts.Length == 3 && string.Equals(title, fileParts[1], StringComparison.OrdinalIgnoreCase); - }).ToList(); + // Only return playable local .m2ts files + return directoryFiles + .Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase)) + .Select(f => f.FullName) + .ToList(); + } - // If this resulted in not getting any vobs, just take them all - if (files.Count == 0) - { - _logger.LogWarning("Vob filename filter resulted in zero matches. Taking all vobs."); - files = allVobs; - } - } + /// <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; } - return files.Select(i => i.FullName); + // 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) diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index e33cfc7a1..a0624fe76 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -22,22 +22,22 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="BDInfo" Version="0.7.6.2" /> - <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.3"> + <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 000000000..d7c68e5f3 --- /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 eab8f79bb..294442324 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 18e248a1b..cb482301f 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -65,7 +65,8 @@ namespace MediaBrowser.MediaEncoding.Probing "K/DA", "22/7", "諭吉佳作/men", - "//dARTH nULL" + "//dARTH nULL", + "Phantom/Ghost", }; public MediaInfo GetMediaInfo(InternalMediaInfoResult data, VideoType? videoType, bool isAudio, string path, MediaProtocol protocol) @@ -96,19 +97,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) { @@ -250,12 +248,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; } @@ -560,8 +569,8 @@ namespace MediaBrowser.MediaEncoding.Probing } } - if (string.IsNullOrWhiteSpace(name) || - string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(name) + || string.IsNullOrWhiteSpace(value)) { return null; } @@ -598,7 +607,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; @@ -626,17 +635,6 @@ namespace MediaBrowser.MediaEncoding.Probing } /// <summary> - /// Determines whether a stream code time base is double the frame rate. - /// </summary> - /// <param name="averageFrameRate">average frame rate.</param> - /// <param name="codecTimeBase">codec time base string.</param> - /// <returns>true if the codec time base is double the frame rate.</returns> - internal static bool IsCodecTimeBaseDoubleTheFrameRate(float? averageFrameRate, string codecTimeBase) - { - return MathF.Abs(((averageFrameRate ?? 0) * (GetFrameRate(codecTimeBase) ?? 0)) - 0.5f) <= float.Epsilon; - } - - /// <summary> /// Converts ffprobe stream info to our MediaStream class. /// </summary> /// <param name="isAudio">if set to <c>true</c> [is info].</param> @@ -661,20 +659,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)) { @@ -688,18 +676,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); @@ -723,7 +708,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); @@ -743,27 +728,14 @@ 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); - bool videoInterlaced = !string.IsNullOrWhiteSpace(streamInfo.FieldOrder) + stream.IsInterlaced = !string.IsNullOrWhiteSpace(streamInfo.FieldOrder) && !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase); - // Some interlaced H.264 files in mp4 containers using MBAFF coding aren't flagged as being interlaced by FFprobe, - // so for H.264 files we also calculate the frame rate from the codec time base and check if it is double the reported - // frame rate to determine if the file is interlaced - - bool h264MbaffCoded = string.Equals(stream.Codec, "h264", StringComparison.OrdinalIgnoreCase) - && string.IsNullOrWhiteSpace(streamInfo.FieldOrder) - && IsCodecTimeBaseDoubleTheFrameRate(stream.AverageFrameRate, stream.CodecTimeBase); - - if (videoInterlaced || h264MbaffCoded) - { - stream.IsInterlaced = true; - } - if (isAudio || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase) || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) @@ -877,35 +849,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; } @@ -918,29 +885,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; + } } } } @@ -971,12 +935,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; } @@ -1007,7 +967,7 @@ namespace MediaBrowser.MediaEncoding.Probing return null; } - return input.Split('(').FirstOrDefault(); + return input.AsSpan().LeftPart('(').ToString(); } private string GetAspectRatio(MediaStreamInfo info) @@ -1015,11 +975,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; @@ -1100,12 +1060,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; } @@ -1121,7 +1075,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; @@ -1151,8 +1105,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; } @@ -1168,7 +1121,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; } @@ -1185,8 +1138,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; } @@ -1478,7 +1430,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; } @@ -1498,7 +1450,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; diff --git a/MediaBrowser.Model/Branding/BrandingOptions.cs b/MediaBrowser.Model/Branding/BrandingOptions.cs index 695267d46..c6580598b 100644 --- a/MediaBrowser.Model/Branding/BrandingOptions.cs +++ b/MediaBrowser.Model/Branding/BrandingOptions.cs @@ -22,7 +22,7 @@ public class BrandingOptions /// <summary> /// Gets or sets a value indicating whether to enable the splashscreen. /// </summary> - public bool SplashscreenEnabled { get; set; } = true; + public bool SplashscreenEnabled { get; set; } = false; /// <summary> /// Gets or sets the splashscreen location on disk. diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 0ff95a2e1..f348d8417 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -39,7 +39,8 @@ public class EncodingOptions 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; diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index d3e042aba..7cb07a2f7 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -243,21 +243,21 @@ 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; } = 0; /// <summary> /// Gets or sets the chapter image resolution. /// </summary> /// <value>The chapter image resolution.</value> public ImageResolution ChapterImageResolution { get; set; } = ImageResolution.MatchSource; + + /// <summary> + /// Gets or sets the limit for parallel image encoding. + /// </summary> + /// <value>The limit for parallel image encoding.</value> + public int ParallelImageEncodingLimit { get; set; } = 0; } } diff --git a/MediaBrowser.Model/Dlna/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs index 573422416..f5e1a3c49 100644 --- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs +++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs @@ -136,12 +136,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 +226,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); diff --git a/MediaBrowser.Model/Dlna/ContainerProfile.cs b/MediaBrowser.Model/Dlna/ContainerProfile.cs index 927df8e4e..978004268 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/DirectPlayProfile.cs b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs index 03c3a7265..f68235d86 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 a70ce44cc..d7397399d 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 29aecf97f..7ec0dd473 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; } + required public 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/SortCriteria.cs b/MediaBrowser.Model/Dlna/SortCriteria.cs index 7fef16e53..7df53c6d1 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 bb41c9979..84c8c012c 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Globalization; @@ -38,53 +36,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 +99,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 +119,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 +171,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 +204,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 +347,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 +375,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 +391,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 +429,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 +531,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 +555,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 +567,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 +614,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 +632,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 +663,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 +734,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)) { @@ -796,7 +788,16 @@ 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); @@ -863,12 +864,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; + string? 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; @@ -904,11 +905,11 @@ 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 && @@ -956,7 +957,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 +990,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); @@ -1082,13 +1083,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 +1112,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; + string? 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; @@ -1174,7 +1175,6 @@ namespace MediaBrowser.Model.Dlna var reason = a & flag; if (reason != 0) { - a = reason; return index; } @@ -1184,6 +1184,8 @@ namespace MediaBrowser.Model.Dlna return index; }; + var containerSupported = false; + // Check DirectPlay profiles to see if it can be direct played var analyzedProfiles = profile.DirectPlayProfiles .Where(directPlayProfile => directPlayProfile.Type == DlnaProfileType.Video) @@ -1197,16 +1199,20 @@ namespace MediaBrowser.Model.Dlna { directPlayProfileReasons |= TranscodeReason.ContainerNotSupported; } + else + { + containerSupported = true; + } // 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)); @@ -1239,7 +1245,7 @@ namespace MediaBrowser.Model.Dlna { playMethod = PlayMethod.DirectPlay; } - else if (directStreamFailureReasons == 0 && isEligibleForDirectStream && mediaSource.SupportsDirectStream && directPlayProfile is not null) + else if (directStreamFailureReasons == 0 && isEligibleForDirectStream && mediaSource.SupportsDirectStream) { playMethod = PlayMethod.DirectStream; } @@ -1261,7 +1267,10 @@ namespace MediaBrowser.Model.Dlna return profileMatch; } - var failureReasons = analyzedProfiles[false].Select(analysis => analysis.Result).FirstOrDefault().TranscodeReason; + var failureReasons = analyzedProfiles[false] + .Select(analysis => analysis.Result) + .Where(result => !containerSupported || (result.TranscodeReason & TranscodeReason.ContainerNotSupported) == 0) + .FirstOrDefault().TranscodeReason; if (failureReasons == 0) { failureReasons = TranscodeReason.DirectPlayError; @@ -1324,8 +1333,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))) { @@ -1398,7 +1407,7 @@ namespace MediaBrowser.Model.Dlna }; } - private static bool IsSubtitleEmbedSupported(string transcodingContainer) + private static bool IsSubtitleEmbedSupported(string? transcodingContainer) { if (!string.IsNullOrEmpty(transcodingContainer)) { @@ -1420,7 +1429,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) { @@ -1553,7 +1562,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, @@ -1573,7 +1582,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) { @@ -1599,7 +1608,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) { @@ -1625,7 +1634,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) { @@ -1661,7 +1670,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) { @@ -1785,7 +1794,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) { @@ -1821,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) { @@ -1911,7 +1920,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) { @@ -1937,7 +1946,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) { @@ -1963,7 +1972,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) { @@ -1989,7 +1998,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) { @@ -2015,7 +2024,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) { @@ -2049,7 +2058,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 3b5509907..886b64a24 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -107,9 +107,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. @@ -922,12 +921,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 +933,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 +945,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 +957,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/ImageOptions.cs b/MediaBrowser.Model/Dto/ImageOptions.cs deleted file mode 100644 index 3f4405f1e..000000000 --- a/MediaBrowser.Model/Dto/ImageOptions.cs +++ /dev/null @@ -1,111 +0,0 @@ -#nullable disable -using MediaBrowser.Model.Drawing; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Model.Dto -{ - /// <summary> - /// Class ImageOptions. - /// </summary> - public class ImageOptions - { - /// <summary> - /// Initializes a new instance of the <see cref="ImageOptions" /> class. - /// </summary> - public ImageOptions() - { - EnableImageEnhancers = true; - } - - /// <summary> - /// Gets or sets the type of the image. - /// </summary> - /// <value>The type of the image.</value> - public ImageType ImageType { get; set; } - - /// <summary> - /// Gets or sets the index of the image. - /// </summary> - /// <value>The index of the image.</value> - public int? ImageIndex { get; set; } - - /// <summary> - /// Gets or sets the width. - /// </summary> - /// <value>The width.</value> - public int? Width { get; set; } - - /// <summary> - /// Gets or sets the height. - /// </summary> - /// <value>The height.</value> - public int? Height { get; set; } - - /// <summary> - /// Gets or sets the width of the max. - /// </summary> - /// <value>The width of the max.</value> - public int? MaxWidth { get; set; } - - /// <summary> - /// Gets or sets the height of the max. - /// </summary> - /// <value>The height of the max.</value> - public int? MaxHeight { get; set; } - - /// <summary> - /// Gets or sets the quality. - /// </summary> - /// <value>The quality.</value> - public int? Quality { get; set; } - - /// <summary> - /// Gets or sets the image tag. - /// If set this will result in strong, unconditional response caching. - /// </summary> - /// <value>The hash.</value> - public string Tag { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [crop whitespace]. - /// </summary> - /// <value><c>null</c> if [crop whitespace] contains no value, <c>true</c> if [crop whitespace]; otherwise, <c>false</c>.</value> - public bool? CropWhitespace { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [enable image enhancers]. - /// </summary> - /// <value><c>true</c> if [enable image enhancers]; otherwise, <c>false</c>.</value> - public bool EnableImageEnhancers { get; set; } - - /// <summary> - /// Gets or sets the format. - /// </summary> - /// <value>The format.</value> - public ImageFormat? Format { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [add played indicator]. - /// </summary> - /// <value><c>true</c> if [add played indicator]; otherwise, <c>false</c>.</value> - public bool AddPlayedIndicator { get; set; } - - /// <summary> - /// Gets or sets the percent played. - /// </summary> - /// <value>The percent played.</value> - public int? PercentPlayed { get; set; } - - /// <summary> - /// Gets or sets the un played count. - /// </summary> - /// <value>The un played count.</value> - public int? UnPlayedCount { get; set; } - - /// <summary> - /// Gets or sets the color of the background. - /// </summary> - /// <value>The color of the background.</value> - public string BackgroundColor { get; set; } - } -} diff --git a/MediaBrowser.Model/Entities/ParentalRating.cs b/MediaBrowser.Model/Entities/ParentalRating.cs index 17b2868a3..c92640818 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/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index e00157dce..02a29e7fa 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 284e89f1c..9a5804485 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.3"> + <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/BlurayDiscInfo.cs b/MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs index 83f982a5c..d546ffccd 100644 --- a/MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs +++ b/MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs @@ -1,39 +1,41 @@ #nullable disable -#pragma warning disable CS1591 using MediaBrowser.Model.Entities; -namespace MediaBrowser.Model.MediaInfo +namespace MediaBrowser.Model.MediaInfo; + +/// <summary> +/// Represents the result of BDInfo output. +/// </summary> +public class BlurayDiscInfo { /// <summary> - /// Represents the result of BDInfo output. + /// Gets or sets the media streams. /// </summary> - public class BlurayDiscInfo - { - /// <summary> - /// Gets or sets the media streams. - /// </summary> - /// <value>The media streams.</value> - public MediaStream[] MediaStreams { get; set; } + /// <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 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 files. + /// </summary> + /// <value>The files.</value> + public string[] Files { get; set; } - public string PlaylistName { 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; } - } + /// <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 index 5b7d1d03c..d39725301 100644 --- a/MediaBrowser.Model/MediaInfo/IBlurayExaminer.cs +++ b/MediaBrowser.Model/MediaInfo/IBlurayExaminer.cs @@ -1,15 +1,14 @@ -namespace MediaBrowser.Model.MediaInfo +namespace MediaBrowser.Model.MediaInfo; + +/// <summary> +/// Interface IBlurayExaminer. +/// </summary> +public interface IBlurayExaminer { /// <summary> - /// Interface IBlurayExaminer. + /// Gets the disc info. /// </summary> - public interface IBlurayExaminer - { - /// <summary> - /// Gets the disc info. - /// </summary> - /// <param name="path">The path.</param> - /// <returns>BlurayDiscInfo.</returns> - BlurayDiscInfo GetDiscInfo(string path); - } + /// <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 8157dc0c2..5a1871070 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/Notifications/NotificationLevel.cs b/MediaBrowser.Model/Notifications/NotificationLevel.cs deleted file mode 100644 index 14fead3f0..000000000 --- a/MediaBrowser.Model/Notifications/NotificationLevel.cs +++ /dev/null @@ -1,11 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Notifications -{ - public enum NotificationLevel - { - Normal = 0, - Warning = 1, - Error = 2 - } -} diff --git a/MediaBrowser.Model/Notifications/NotificationOption.cs b/MediaBrowser.Model/Notifications/NotificationOption.cs deleted file mode 100644 index 58aecb3d3..000000000 --- a/MediaBrowser.Model/Notifications/NotificationOption.cs +++ /dev/null @@ -1,55 +0,0 @@ -#pragma warning disable CA1819 // Properties should not return arrays -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Notifications -{ - public class NotificationOption - { - public NotificationOption(string type) - { - Type = type; - DisabledServices = Array.Empty<string>(); - DisabledMonitorUsers = Array.Empty<string>(); - SendToUsers = Array.Empty<string>(); - } - - public NotificationOption() - { - DisabledServices = Array.Empty<string>(); - DisabledMonitorUsers = Array.Empty<string>(); - SendToUsers = Array.Empty<string>(); - } - - public string? Type { get; set; } - - /// <summary> - /// Gets or sets user Ids to not monitor (it's opt out). - /// </summary> - public string[] DisabledMonitorUsers { get; set; } - - /// <summary> - /// Gets or sets user Ids to send to (if SendToUserMode == Custom). - /// </summary> - public string[] SendToUsers { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this <see cref="NotificationOption"/> is enabled. - /// </summary> - /// <value><c>true</c> if enabled; otherwise, <c>false</c>.</value> - public bool Enabled { get; set; } - - /// <summary> - /// Gets or sets the disabled services. - /// </summary> - /// <value>The disabled services.</value> - public string[] DisabledServices { get; set; } - - /// <summary> - /// Gets or sets the send to user mode. - /// </summary> - /// <value>The send to user mode.</value> - public SendToUserType SendToUserMode { get; set; } - } -} diff --git a/MediaBrowser.Model/Notifications/NotificationOptions.cs b/MediaBrowser.Model/Notifications/NotificationOptions.cs deleted file mode 100644 index 804f51e16..000000000 --- a/MediaBrowser.Model/Notifications/NotificationOptions.cs +++ /dev/null @@ -1,131 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Extensions; - -namespace MediaBrowser.Model.Notifications -{ - public class NotificationOptions - { - public NotificationOptions() - { - Options = new[] - { - new NotificationOption(NotificationType.TaskFailed.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.ServerRestartRequired.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.ApplicationUpdateAvailable.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.ApplicationUpdateInstalled.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.PluginUpdateInstalled.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.PluginUninstalled.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.InstallationFailed.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.PluginInstalled.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.PluginError.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - }, - new NotificationOption(NotificationType.UserLockedOut.ToString()) - { - Enabled = true, - SendToUserMode = SendToUserType.Admins - } - }; - } - - public NotificationOption[] Options { get; set; } - - public NotificationOption GetOptions(string type) - { - foreach (NotificationOption i in Options) - { - if (string.Equals(type, i.Type, StringComparison.OrdinalIgnoreCase)) - { - return i; - } - } - - return null; - } - - public bool IsEnabled(string type) - { - NotificationOption opt = GetOptions(type); - - return opt is not null && opt.Enabled; - } - - public bool IsServiceEnabled(string service, string notificationType) - { - NotificationOption opt = GetOptions(notificationType); - - return opt is null - || !opt.DisabledServices.Contains(service, StringComparison.OrdinalIgnoreCase); - } - - public bool IsEnabledToMonitorUser(string type, Guid userId) - { - NotificationOption opt = GetOptions(type); - - return opt is not null - && opt.Enabled - && !opt.DisabledMonitorUsers.Contains(userId.ToString("N"), StringComparison.OrdinalIgnoreCase); - } - - public bool IsEnabledToSendToUser(string type, string userId, User user) - { - NotificationOption opt = GetOptions(type); - - if (opt is not null && opt.Enabled) - { - if (opt.SendToUserMode == SendToUserType.All) - { - return true; - } - - if (opt.SendToUserMode == SendToUserType.Admins && user.HasPermission(PermissionKind.IsAdministrator)) - { - return true; - } - - return opt.SendToUsers.Contains(userId, StringComparison.OrdinalIgnoreCase); - } - - return false; - } - } -} diff --git a/MediaBrowser.Model/Notifications/NotificationRequest.cs b/MediaBrowser.Model/Notifications/NotificationRequest.cs deleted file mode 100644 index 622c50cd8..000000000 --- a/MediaBrowser.Model/Notifications/NotificationRequest.cs +++ /dev/null @@ -1,35 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; - -namespace MediaBrowser.Model.Notifications -{ - public class NotificationRequest - { - public NotificationRequest() - { - UserIds = Array.Empty<Guid>(); - Date = DateTime.UtcNow; - } - - public string Name { get; set; } - - public string Description { get; set; } - - public string Url { get; set; } - - public NotificationLevel Level { get; set; } - - public Guid[] UserIds { get; set; } - - public DateTime Date { get; set; } - - /// <summary> - /// Gets or sets the corresponding type name used in configuration. Not for display. - /// </summary> - public string NotificationType { get; set; } - - public SendToUserType? SendToUserMode { get; set; } - } -} diff --git a/MediaBrowser.Model/Notifications/NotificationTypeInfo.cs b/MediaBrowser.Model/Notifications/NotificationTypeInfo.cs deleted file mode 100644 index 402fbe81a..000000000 --- a/MediaBrowser.Model/Notifications/NotificationTypeInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Notifications -{ - public class NotificationTypeInfo - { - public string Type { get; set; } - - public string Name { get; set; } - - public bool Enabled { get; set; } - - public string Category { get; set; } - - public bool IsBasedOnUserEvent { get; set; } - } -} diff --git a/MediaBrowser.Model/Notifications/SendToUserType.cs b/MediaBrowser.Model/Notifications/SendToUserType.cs deleted file mode 100644 index 65fc4e1ab..000000000 --- a/MediaBrowser.Model/Notifications/SendToUserType.cs +++ /dev/null @@ -1,11 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Notifications -{ - public enum SendToUserType - { - All = 0, - Admins = 1, - Custom = 2 - } -} diff --git a/MediaBrowser.Model/System/OperatingSystemId.cs b/MediaBrowser.Model/System/OperatingSystemId.cs deleted file mode 100644 index 2e417f6b5..000000000 --- a/MediaBrowser.Model/System/OperatingSystemId.cs +++ /dev/null @@ -1,12 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.System -{ - public enum OperatingSystemId - { - Windows, - Linux, - Darwin, - BSD - } -} diff --git a/MediaBrowser.Model/System/PublicSystemInfo.cs b/MediaBrowser.Model/System/PublicSystemInfo.cs index 53030843a..31a895642 100644 --- a/MediaBrowser.Model/System/PublicSystemInfo.cs +++ b/MediaBrowser.Model/System/PublicSystemInfo.cs @@ -1,6 +1,8 @@ #nullable disable #pragma warning disable CS1591 +using System; + namespace MediaBrowser.Model.System { public class PublicSystemInfo @@ -32,7 +34,8 @@ namespace MediaBrowser.Model.System /// Gets or sets the operating system. /// </summary> /// <value>The operating system.</value> - public string OperatingSystem { get; set; } + [Obsolete("This is no longer set")] + public string OperatingSystem { get; set; } = string.Empty; /// <summary> /// Gets or sets the id. diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index a82c1c8c0..bd0099af7 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -42,7 +42,8 @@ namespace MediaBrowser.Model.System /// Gets or sets the display name of the operating system. /// </summary> /// <value>The display name of the operating system.</value> - public string OperatingSystemDisplayName { get; set; } + [Obsolete("This is no longer set")] + public string OperatingSystemDisplayName { get; set; } = string.Empty; /// <summary> /// Gets or sets the package name. @@ -79,8 +80,9 @@ namespace MediaBrowser.Model.System /// <summary> /// Gets or sets a value indicating whether this instance can self restart. /// </summary> - /// <value><c>true</c> if this instance can self restart; otherwise, <c>false</c>.</value> - public bool CanSelfRestart { get; set; } + /// <value><c>true</c>.</value> + [Obsolete("This is always true")] + public bool CanSelfRestart { get; set; } = true; public bool CanLaunchWebBrowser { get; set; } @@ -136,6 +138,7 @@ namespace MediaBrowser.Model.System [Obsolete("This isn't set correctly anymore")] public FFmpegLocation EncoderLocation { get; set; } - public Architecture SystemArchitecture { get; set; } + [Obsolete("This is no longer set")] + public Architecture SystemArchitecture { get; set; } = Architecture.X64; } } diff --git a/MediaBrowser.Model/Tasks/ITaskManager.cs b/MediaBrowser.Model/Tasks/ITaskManager.cs index 13bebc479..5b55667e8 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 3634d0705..80f5e2c37 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -13,6 +13,7 @@ namespace MediaBrowser.Model.Users public UserPolicy() { IsHidden = true; + EnableCollectionManagement = false; EnableContentDeletion = false; EnableContentDeletionFromFolders = Array.Empty<string>(); @@ -35,6 +36,7 @@ namespace MediaBrowser.Model.Users EnableSharedDeviceControl = true; BlockedTags = Array.Empty<string>(); + AllowedTags = Array.Empty<string>(); BlockUnratedItems = Array.Empty<UnratedItem>(); EnableUserPreferenceAccess = true; @@ -44,6 +46,7 @@ namespace MediaBrowser.Model.Users LoginAttemptsBeforeLockout = -1; MaxActiveSessions = 0; + MaxParentalRating = null; EnableAllChannels = true; EnabledChannels = Array.Empty<Guid>(); @@ -73,6 +76,12 @@ 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> + 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 +95,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/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index d621555f1..5d59c4663 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -313,7 +313,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) { diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index ffae77200..0605b0bd7 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -151,7 +151,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 +333,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 +380,7 @@ namespace MediaBrowser.Providers.Manager if (!folder.RunTimeTicks.HasValue || folder.RunTimeTicks.Value != ticks) { folder.RunTimeTicks = ticks; - return ItemUpdateType.MetadataEdit; + return ItemUpdateType.MetadataImport; } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 0ce696edc..81ccd8653 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -284,12 +284,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>(); } } @@ -910,19 +910,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)); - - _activeRefreshes.Remove(item.Id, out _); + _logger.LogDebug("OnRefreshComplete {Item:N}", item.Id); + _activeRefreshes.TryRemove(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 +955,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 +968,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/> diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 97ad1ffbc..6a40833d7 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -8,7 +8,6 @@ <ItemGroup> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> - <ProjectReference Include="..\DvdLib\DvdLib.csproj" /> </ItemGroup> <ItemGroup> @@ -16,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> @@ -36,13 +35,13 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <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 74210b1f2..19b594c1c 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -105,7 +105,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); } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 81434b862..213639371 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -9,7 +9,6 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using DvdLib.Ifo; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Configuration; @@ -90,36 +89,66 @@ namespace MediaBrowser.Providers.MediaInfo if (!item.IsShortcut || options.EnableRemoteContentProbe) { - string[] streamFileNames = null; - if (item.VideoType == VideoType.Dvd) { - streamFileNames = FetchFromDvdLib(item); + // Get list of playable .vob files + var vobs = _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, null); - if (streamFileNames.Length == 0) + // Return if no playable .vob files are found + if (vobs.Count == 0) { - _logger.LogError("No playable vobs found in dvd structure, skipping ffprobe."); + _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) { - var inputPath = item.Path; - - blurayDiscInfo = GetBDInfo(inputPath); + // Get BD disc information + blurayDiscInfo = GetBDInfo(item.Path); - streamFileNames = blurayDiscInfo.Files; + // Get playable .m2ts files + var m2ts = _mediaEncoder.GetPrimaryPlaylistM2tsFiles(item.Path); - if (streamFileNames.Length == 0) + // Return if no playable .m2ts files are found + if (blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0) { - _logger.LogError("No playable vobs found in bluray structure, skipping ffprobe."); + _logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe."); return ItemUpdateType.MetadataImport; } - } - - streamFileNames ??= Array.Empty<string>(); - mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false); + // 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(); } @@ -189,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) @@ -280,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); } @@ -317,60 +335,58 @@ namespace MediaBrowser.Providers.MediaInfo } } - private void FetchBdInfo(BaseItem item, ref ChapterInfo[] chapters, List<MediaStream> mediaStreams, BlurayDiscInfo blurayInfo) + private void FetchBdInfo(Video video, ref ChapterInfo[] chapters, List<MediaStream> mediaStreams, BlurayDiscInfo blurayInfo) { - var video = (Video)item; - - // video.PlayableStreamFileNames = blurayInfo.Files.ToList(); + 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 - if (blurayInfo.Files.Length > 1) - { - int? currentHeight = null; - int? currentWidth = null; - int? currentBitRate = null; + int? currentHeight = null; + int? currentWidth = null; + int? currentBitRate = null; - var videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video); + 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; - } + // 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); + // 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.RunTimeTicks.HasValue && blurayInfo.RunTimeTicks.Value > 0) + { + video.RunTimeTicks = blurayInfo.RunTimeTicks; + } - if (blurayInfo.Chapters is not null) + if (blurayInfo.Chapters is not null) + { + double[] brChapter = blurayInfo.Chapters; + chapters = new ChapterInfo[brChapter.Length]; + for (int i = 0; i < brChapter.Length; i++) { - double[] brChapter = blurayInfo.Chapters; - chapters = new ChapterInfo[brChapter.Length]; - for (int i = 0; i < brChapter.Length; i++) + chapters[i] = new ChapterInfo { - chapters[i] = new ChapterInfo - { - StartPositionTicks = TimeSpan.FromSeconds(brChapter[i]).Ticks - }; - } + StartPositionTicks = TimeSpan.FromSeconds(brChapter[i]).Ticks + }; } + } - videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video); + 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; - } + // 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; } } @@ -386,10 +402,7 @@ namespace MediaBrowser.Providers.MediaInfo /// <returns>VideoStream.</returns> private BlurayDiscInfo GetBDInfo(string path) { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentNullException(nameof(path)); - } + ArgumentException.ThrowIfNullOrEmpty(path); try { @@ -649,67 +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 - }; - - currentChapterTicks += dummyChapterDuration; - } - - return chapters; - } - - private string[] FetchFromDvdLib(Video item) - { - var path = item.Path; - var dvd = new Dvd(path); - - var primaryTitle = dvd.Titles.OrderByDescending(GetRuntime).FirstOrDefault(); + chapters[i] = new ChapterInfo + { + StartPositionTicks = currentChapterTicks + }; - byte? titleNumber = null; + currentChapterTicks += dummyChapterDuration; + } - if (primaryTitle is not null) - { - titleNumber = primaryTitle.VideoTitleSetNumber; - item.RunTimeTicks = GetRuntime(primaryTitle); + return chapters; } - return _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, titleNumber) - .Select(Path.GetFileName) - .ToArray(); - } - - private long GetRuntime(Title title) - { - return title.ProgramChains - .Select(i => (TimeSpan)i.PlaybackTime) - .Select(i => i.Ticks) - .Sum(); + return Array.Empty<ChapterInfo>(); } } } diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs index 58cd23aa3..3476e7000 100644 --- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs +++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs @@ -54,6 +54,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)) diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index db4c5f436..9bd36f25c 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 b1a285a96..2232dfa0d 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 eab252005..2093effca 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 22229e377..21a15c58c 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 6f1296bb7..24f2ac0ca 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 34f45f0d5..d0bd7d609 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 718b5a1c4..1323d2604 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 f4375b3cb..d00c1f9f8 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 60b373483..140a64f52 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 497437bd8..dfaba6423 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -98,8 +98,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 +208,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; @@ -552,7 +550,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 63750dbcd..85ebe443f 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 0fb9d30a6..ae244da19 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 }; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs index ac3df1d5d..450ee2a33 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 eee3658de..a4c6cb47d 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 1cce7fc35..c2018d820 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 48ec0535c..cd21516f9 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 02601d3f5..bfec48e7c 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 9eced93fa..fc7202366 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; @@ -64,32 +62,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 +98,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) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index bc959ee2b..9e5404b32 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 b3709baf5..5c6e71fd8 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 5259faf76..d1fec7cb1 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 35e304a2a..66decde84 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; @@ -87,7 +85,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) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index b8d1460db..a743601ed 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/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 79cb6e86d..192fb052d 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 959088210..09d1a739d 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; @@ -211,7 +209,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - if (string.IsNullOrEmpty(tmdbId)) + if (!int.TryParse(tmdbId, CultureInfo.InvariantCulture, out int tmdbIdInt)) { return result; } @@ -219,9 +217,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), diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index c7441bf35..500ebaf71 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 44c2c81f4..b326d22c8 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; @@ -128,7 +129,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 b1a26cfba..0c01c5031 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 a261d7cdb..97f938397 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; diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index 6e82d96d1..807234915 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.3"> + <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 96c512a46..8bd30447a 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -266,11 +266,6 @@ namespace MediaBrowser.XbmcMetadata.Parsers var nfoConfiguration = _config.GetNfoConfiguration(); UserItemData? userData = null; - if (!string.IsNullOrWhiteSpace(nfoConfiguration.UserId)) - { - var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId)); - userData = _userDataManager.GetUserData(user, item); - } switch (reader.Name) { @@ -279,16 +274,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; @@ -320,12 +312,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; @@ -370,9 +359,12 @@ namespace MediaBrowser.XbmcMetadata.Parsers { var val = reader.ReadElementContentAsBoolean(); - if (userData is not null) + if (!string.IsNullOrWhiteSpace(nfoConfiguration.UserId)) { + var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId)); + userData = _userDataManager.GetUserData(user, item); userData.Played = val; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); } break; @@ -381,12 +373,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "playcount": { var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val) && userData is not null) + 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)) - { - userData.PlayCount = count; - } + var user = _userManager.GetUserById(guid); + userData = _userDataManager.GetUserData(user, item); + userData.PlayCount = count; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); } break; @@ -395,12 +388,15 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "lastplayed": { var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val) && userData is not null) + 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); + userData = _userDataManager.GetUserData(user, item); userData.LastPlayedDate = added; - } + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } else { Logger.LogWarning("Invalid lastplayed value found: {Value}", val); @@ -486,12 +482,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; @@ -629,13 +622,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; @@ -645,12 +634,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; @@ -660,13 +646,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; @@ -696,13 +679,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; @@ -714,12 +694,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; @@ -1190,21 +1167,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; } } @@ -1288,12 +1265,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/debian/bin/restart.sh b/debian/bin/restart.sh deleted file mode 100755 index 4847b918b..000000000 --- a/debian/bin/restart.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash - -# restart.sh - Jellyfin server restart script -# Part of the Jellyfin project (https://github.com/jellyfin) -# -# This script restarts the Jellyfin daemon on Linux when using -# the Restart button on the admin dashboard. It supports the -# systemctl, service, and traditional /etc/init.d (sysv) restart -# methods, chosen automatically by which one is found first (in -# that order). -# -# This script is used by the Debian/Ubuntu/Fedora/CentOS packages. - -# This is the Right Way(tm) to check if we are booted with -# systemd, according to sd_booted(3) -if [ -d /run/systemd/system ]; then - cmd=systemctl -else - # Everything else is really hard to figure out, so we just use - # service(8) if it's available - that works with most init - # systems/distributions I know of, including FreeBSD - if type service >/dev/null 2>&1; then - cmd=service - else - # If even service(8) isn't available, we just try /etc/init.d - # and hope for the best - if [ -d /etc/init.d ]; then - cmd=sysv - else - echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 - echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 - exit 1 - fi - fi -fi - -if type sudo >/dev/null 2>&1; then - sudo_command=sudo -else - sudo_command= -fi - -echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." -case $cmd in - 'systemctl') - # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too - $sudo_command systemd-run systemctl restart jellyfin - ;; - 'service') - echo "sleep 0.5; $sudo_command service jellyfin start" | at now - ;; - 'sysv') - echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now - ;; -esac -exit 0 diff --git a/debian/conf/jellyfin b/debian/conf/jellyfin index 2f0630a9c..912996755 100644 --- a/debian/conf/jellyfin +++ b/debian/conf/jellyfin @@ -21,9 +21,6 @@ JELLYFIN_CACHE_DIR="/var/cache/jellyfin" # web client path, installed by the jellyfin-web package JELLYFIN_WEB_OPT="--webdir=/usr/share/jellyfin/web" -# Restart script for in-app server control -JELLYFIN_RESTART_OPT="--restartpath=/usr/lib/jellyfin/restart.sh" - # ffmpeg binary paths, overriding the system values JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/lib/jellyfin-ffmpeg/ffmpeg" @@ -50,4 +47,4 @@ JELLYFIN_ADDITIONAL_OPTS="" # Application username JELLYFIN_USER="jellyfin" # Full application command -JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS" +JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS" diff --git a/debian/conf/jellyfin-sudoers b/debian/conf/jellyfin-sudoers deleted file mode 100644 index 795fd17e8..000000000 --- a/debian/conf/jellyfin-sudoers +++ /dev/null @@ -1,33 +0,0 @@ -#Allow jellyfin group to start, stop and restart itself -Cmnd_Alias RESTARTSERVER_SYSV = /sbin/service jellyfin restart, /usr/sbin/service jellyfin restart -Cmnd_Alias STARTSERVER_SYSV = /sbin/service jellyfin start, /usr/sbin/service jellyfin start -Cmnd_Alias STOPSERVER_SYSV = /sbin/service jellyfin stop, /usr/sbin/service jellyfin stop -Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl restart jellyfin -Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl start jellyfin -Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemd-run systemctl stop jellyfin -Cmnd_Alias RESTARTSERVER_INITD = /etc/init.d/jellyfin restart -Cmnd_Alias STARTSERVER_INITD = /etc/init.d/jellyfin start -Cmnd_Alias STOPSERVER_INITD = /etc/init.d/jellyfin stop - - -jellyfin ALL=(ALL) NOPASSWD: RESTARTSERVER_SYSV -jellyfin ALL=(ALL) NOPASSWD: STARTSERVER_SYSV -jellyfin ALL=(ALL) NOPASSWD: STOPSERVER_SYSV -jellyfin ALL=(ALL) NOPASSWD: RESTARTSERVER_SYSTEMD -jellyfin ALL=(ALL) NOPASSWD: STARTSERVER_SYSTEMD -jellyfin ALL=(ALL) NOPASSWD: STOPSERVER_SYSTEMD -jellyfin ALL=(ALL) NOPASSWD: RESTARTSERVER_INITD -jellyfin ALL=(ALL) NOPASSWD: STARTSERVER_INITD -jellyfin ALL=(ALL) NOPASSWD: STOPSERVER_INITD - -Defaults!RESTARTSERVER_SYSV !requiretty -Defaults!STARTSERVER_SYSV !requiretty -Defaults!STOPSERVER_SYSV !requiretty -Defaults!RESTARTSERVER_SYSTEMD !requiretty -Defaults!STARTSERVER_SYSTEMD !requiretty -Defaults!STOPSERVER_SYSTEMD !requiretty -Defaults!RESTARTSERVER_INITD !requiretty -Defaults!STARTSERVER_INITD !requiretty -Defaults!STOPSERVER_INITD !requiretty - -Defaults:jellyfin !requiretty diff --git a/debian/control b/debian/control index 08c0dcda6..0b9dd570e 100644 --- a/debian/control +++ b/debian/control @@ -18,11 +18,10 @@ Package: jellyfin-server Replaces: jellyfin (<<10.6.0) Breaks: jellyfin (<<10.6.0) Architecture: any -Depends: at, - libsqlite3-0, +Depends: libsqlite3-0, libfontconfig1, libfreetype6, libssl1.1 | libssl3 -Recommends: jellyfin-web, sudo +Recommends: jellyfin-web Description: Jellyfin is the Free Software Media System. This package provides the Jellyfin server backend and API. diff --git a/debian/install b/debian/install index 994322d14..0b48dd7a2 100644 --- a/debian/install +++ b/debian/install @@ -2,5 +2,3 @@ usr/lib/jellyfin usr/lib/ debian/conf/jellyfin etc/default/ debian/conf/logging.json etc/jellyfin/ debian/conf/jellyfin.service.conf etc/systemd/system/jellyfin.service.d/ -debian/conf/jellyfin-sudoers etc/sudoers.d/ -debian/bin/restart.sh usr/lib/jellyfin/ diff --git a/debian/jellyfin.service b/debian/jellyfin.service index 1150924a0..2cc49f7c4 100644 --- a/debian/jellyfin.service +++ b/debian/jellyfin.service @@ -8,7 +8,7 @@ EnvironmentFile = /etc/default/jellyfin User = jellyfin Group = jellyfin WorkingDirectory = /var/lib/jellyfin -ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS +ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS Restart = on-failure TimeoutSec = 15 SuccessExitStatus=0 143 diff --git a/debian/postinst b/debian/postinst index a15442c76..947959aa7 100644 --- a/debian/postinst +++ b/debian/postinst @@ -59,8 +59,6 @@ case "$1" in chgrp adm $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA chmod 0750 $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA - chmod +x /usr/lib/jellyfin/restart.sh > /dev/null 2>&1 || true - # Install jellyfin symlink into /usr/bin ln -sf /usr/lib/jellyfin/bin/jellyfin /usr/bin/jellyfin diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index e02087a52..36435194f 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/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-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 6962b6bc1..c8943a399 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -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/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-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 96e3ca403..7b9a3de4e 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/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-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 f1c536399..32695e3f1 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/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-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 eaea305d1..8ffbeafad 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/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-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/fedora/jellyfin.env b/fedora/jellyfin.env index 1ccd8196f..1f79fac4f 100644 --- a/fedora/jellyfin.env +++ b/fedora/jellyfin.env @@ -23,9 +23,6 @@ JELLYFIN_CACHE_DIR="/var/cache/jellyfin" # web client path, installed by the jellyfin-web package # JELLYFIN_WEB_OPT="--webdir=/usr/share/jellyfin-web" -# In-App service control -JELLYFIN_RESTART_OPT="--restartpath=/usr/libexec/jellyfin/restart.sh" - # [OPTIONAL] ffmpeg binary paths, overriding the UI-configured values #JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/bin/ffmpeg" diff --git a/fedora/jellyfin.service b/fedora/jellyfin.service index 2fb9f30e0..1b3f8032c 100644 --- a/fedora/jellyfin.service +++ b/fedora/jellyfin.service @@ -8,7 +8,7 @@ EnvironmentFile = /etc/sysconfig/jellyfin User = jellyfin Group = jellyfin WorkingDirectory = /var/lib/jellyfin -ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS +ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS Restart = on-failure TimeoutSec = 15 SuccessExitStatus=0 143 diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index 08de71537..a759b29b1 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -16,11 +16,9 @@ URL: https://jellyfin.org Source0: jellyfin-server-%{version}.tar.gz Source11: jellyfin.service Source12: jellyfin.env -Source13: jellyfin.sudoers -Source14: restart.sh -Source15: jellyfin.override.conf -Source16: jellyfin-firewalld.xml -Source17: jellyfin-server-lowports.conf +Source13: jellyfin.override.conf +Source14: jellyfin-firewalld.xml +Source15: jellyfin-server-lowports.conf %{?systemd_requires} BuildRequires: systemd @@ -44,7 +42,7 @@ Jellyfin is a free software media system that puts you in control of managing an Summary: The Free Software Media System Server backend Requires(pre): shadow-utils Requires: ffmpeg -Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at, sudo +Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu %description server The Jellyfin media server backend. @@ -76,16 +74,14 @@ dotnet publish --configuration Release --self-contained --runtime %{dotnet_runti %{__mkdir} -p %{buildroot}%{_libdir}/jellyfin %{buildroot}%{_bindir} %{__cp} -r Jellyfin.Server/bin/Release/net7.0/%{dotnet_runtime}/publish/* %{buildroot}%{_libdir}/jellyfin ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin -%{__install} -D %{SOURCE14} %{buildroot}%{_libexecdir}/jellyfin/restart.sh # Jellyfin config %{__install} -D Jellyfin.Server/Resources/Configuration/logging.json %{buildroot}%{_sysconfdir}/jellyfin/logging.json %{__install} -D %{SOURCE12} %{buildroot}%{_sysconfdir}/sysconfig/jellyfin # system config -%{__install} -D %{SOURCE16} %{buildroot}%{_prefix}/lib/firewalld/services/jellyfin.xml -%{__install} -D %{SOURCE13} %{buildroot}%{_sysconfdir}/sudoers.d/jellyfin-sudoers -%{__install} -D %{SOURCE15} %{buildroot}%{_sysconfdir}/systemd/system/jellyfin.service.d/override.conf +%{__install} -D %{SOURCE14} %{buildroot}%{_prefix}/lib/firewalld/services/jellyfin.xml +%{__install} -D %{SOURCE13} %{buildroot}%{_sysconfdir}/systemd/system/jellyfin.service.d/override.conf %{__install} -D %{SOURCE11} %{buildroot}%{_unitdir}/jellyfin.service # empty directories @@ -95,7 +91,7 @@ ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin %{__mkdir} -p %{buildroot}%{_var}/log/jellyfin # jellyfin-server-lowports subpackage -%{__install} -D -m 0644 %{SOURCE17} %{buildroot}%{_unitdir}/jellyfin.service.d/jellyfin-server-lowports.conf +%{__install} -D -m 0644 %{SOURCE15} %{buildroot}%{_unitdir}/jellyfin.service.d/jellyfin-server-lowports.conf %files @@ -110,7 +106,6 @@ ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin %attr(755,root,root) %{_libdir}/jellyfin/createdump %attr(755,root,root) %{_libdir}/jellyfin/jellyfin %{_libdir}/jellyfin/* -%attr(755,root,root) %{_libexecdir}/jellyfin/restart.sh # Jellyfin config %config(noreplace) %attr(644,jellyfin,jellyfin) %{_sysconfdir}/jellyfin/logging.json @@ -119,7 +114,6 @@ ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin # system config %{_prefix}/lib/firewalld/services/jellyfin.xml %{_unitdir}/jellyfin.service -%config(noreplace) %attr(600,root,root) %{_sysconfdir}/sudoers.d/jellyfin-sudoers %config(noreplace) %{_sysconfdir}/systemd/system/jellyfin.service.d/override.conf # empty directories diff --git a/fedora/jellyfin.sudoers b/fedora/jellyfin.sudoers deleted file mode 100644 index 01c7f4e11..000000000 --- a/fedora/jellyfin.sudoers +++ /dev/null @@ -1,14 +0,0 @@ -# Allow jellyfin group to start, stop and restart itself -Cmnd_Alias RESTARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl restart jellyfin -Cmnd_Alias STARTSERVER_SYSTEMD = /usr/bin/systemd-run systemctl start jellyfin -Cmnd_Alias STOPSERVER_SYSTEMD = /usr/bin/systemd-run systemctl stop jellyfin - -jellyfin ALL=(ALL) NOPASSWD: RESTARTSERVER_SYSTEMD -jellyfin ALL=(ALL) NOPASSWD: STARTSERVER_SYSTEMD -jellyfin ALL=(ALL) NOPASSWD: STOPSERVER_SYSTEMD - -Defaults!RESTARTSERVER_SYSTEMD !requiretty -Defaults!STARTSERVER_SYSTEMD !requiretty -Defaults!STOPSERVER_SYSTEMD !requiretty - -Defaults:jellyfin !requiretty diff --git a/fedora/restart.sh b/fedora/restart.sh deleted file mode 100755 index 4847b918b..000000000 --- a/fedora/restart.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash - -# restart.sh - Jellyfin server restart script -# Part of the Jellyfin project (https://github.com/jellyfin) -# -# This script restarts the Jellyfin daemon on Linux when using -# the Restart button on the admin dashboard. It supports the -# systemctl, service, and traditional /etc/init.d (sysv) restart -# methods, chosen automatically by which one is found first (in -# that order). -# -# This script is used by the Debian/Ubuntu/Fedora/CentOS packages. - -# This is the Right Way(tm) to check if we are booted with -# systemd, according to sd_booted(3) -if [ -d /run/systemd/system ]; then - cmd=systemctl -else - # Everything else is really hard to figure out, so we just use - # service(8) if it's available - that works with most init - # systems/distributions I know of, including FreeBSD - if type service >/dev/null 2>&1; then - cmd=service - else - # If even service(8) isn't available, we just try /etc/init.d - # and hope for the best - if [ -d /etc/init.d ]; then - cmd=sysv - else - echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 - echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 - exit 1 - fi - fi -fi - -if type sudo >/dev/null 2>&1; then - sudo_command=sudo -else - sudo_command= -fi - -echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." -case $cmd in - 'systemctl') - # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too - $sudo_command systemd-run systemctl restart jellyfin - ;; - 'service') - echo "sleep 0.5; $sudo_command service jellyfin start" | at now - ;; - 'sysv') - echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now - ;; -esac -exit 0 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 9c2449da4..1e3f8a048 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.0" /> + <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 5e7d14b11..20bc4c724 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.0" /> + <PackageReference Include="SharpFuzz" /> </ItemGroup> </Project> diff --git a/nuget.config b/nuget.config deleted file mode 100644 index 326331f32..000000000 --- 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 c686b229a..3b0333299 100644 --- a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -16,11 +16,11 @@ </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" /> </ItemGroup> <ItemGroup> @@ -31,13 +31,13 @@ <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <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/PlayedIndicatorDrawer.cs b/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs deleted file mode 100644 index 5bb42fb99..000000000 --- a/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs +++ /dev/null @@ -1,47 +0,0 @@ -using MediaBrowser.Model.Drawing; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia; - -/// <summary> -/// Static helper class for drawing 'played' indicators. -/// </summary> -public static class PlayedIndicatorDrawer -{ - private const int OffsetFromTopRightCorner = 38; - - /// <summary> - /// Draw a 'played' indicator in the top right corner of a canvas. - /// </summary> - /// <param name="canvas">The canvas to draw the indicator on.</param> - /// <param name="imageSize"> - /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the - /// indicator. - /// </param> - public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize) - { - var x = imageSize.Width - OffsetFromTopRightCorner; - - using var paint = new SKPaint - { - Color = SKColor.Parse("#CC00A4DC"), - Style = SKPaintStyle.Fill - }; - - canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); - - paint.Color = new SKColor(255, 255, 255, 255); - paint.TextSize = 30; - paint.IsAntialias = true; - - // or: - // var emojiChar = 0x1F680; - const string Text = "✔️"; - var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32); - - // ask the font manager for a font with that character - paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar); - - canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint); - } -} diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index ddb8a98d4..6da77ad95 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -399,7 +399,7 @@ public class SkiaEncoder : IImageEncoder var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); var blur = options.Blur ?? 0; - var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); + var hasIndicator = options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); using var bitmap = GetBitmap(inputPath, autoOrient, orientation); if (bitmap is null) @@ -522,11 +522,7 @@ public class SkiaEncoder : IImageEncoder { var currentImageSize = new ImageDimensions(imageWidth, imageHeight); - if (options.AddPlayedIndicator) - { - PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize); - } - else if (options.UnplayedCount.HasValue) + if (options.UnplayedCount.HasValue) { UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value); } diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index b381c9ae7..533baba4f 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -5,10 +5,12 @@ using System.IO; using System.Linq; using System.Net.Mime; using System.Text; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.MediaEncoding; @@ -36,7 +38,8 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable private readonly IFileSystem _fileSystem; private readonly IServerApplicationPaths _appPaths; private readonly IImageEncoder _imageEncoder; - private readonly IMediaEncoder _mediaEncoder; + + private readonly SemaphoreSlim _parallelEncodingLimit; private bool _disposed; @@ -48,18 +51,27 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable /// <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) + IMediaEncoder mediaEncoder, + IServerConfigurationManager config) { _logger = logger; _fileSystem = fileSystem; _imageEncoder = imageEncoder; - _mediaEncoder = mediaEncoder; _appPaths = appPaths; + + var semaphoreCount = config.Configuration.ParallelImageEncodingLimit; + if (semaphoreCount < 1) + { + semaphoreCount = 2 * Environment.ProcessorCount; + } + + _parallelEncodingLimit = new(semaphoreCount, semaphoreCount); } private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); @@ -188,7 +200,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable quality, dateModified, outputFormat, - options.AddPlayedIndicator, options.PercentPlayed, options.UnplayedCount, options.Blur, @@ -199,7 +210,18 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable { if (!File.Exists(cacheFilePath)) { - string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); + // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage + await _parallelEncodingLimit.WaitAsync().ConfigureAwait(false); + + string resultPath; + try + { + resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); + } + finally + { + _parallelEncodingLimit.Release(); + } if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) { @@ -270,7 +292,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable int quality, DateTime dateModified, ImageFormat format, - bool addPlayedIndicator, double percentPlayed, int? unwatchedCount, int? blur, @@ -325,11 +346,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable filename.Append(fillHeight.Value); } - if (addPlayedIndicator) - { - filename.Append(",pl=true"); - } - if (percentPlayed > 0) { filename.Append(",p="); @@ -563,6 +579,8 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable disposable.Dispose(); } + _parallelEncodingLimit?.Dispose(); + _disposed = true; } } diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index a5bc8eaa7..e0963ac34 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.3"> + <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 1b19752bb..6e451d40e 100644 --- a/src/Jellyfin.Extensions/AlphanumericComparator.cs +++ b/src/Jellyfin.Extensions/AlphanumericComparator.cs @@ -86,47 +86,12 @@ namespace Jellyfin.Extensions { return 1; } - else if (span1Len >= 20) // Number is probably too big for a ulong - { - // Trim all the first digits that are the same - int i = 0; - while (i < span1Len && span1[i] == span2[i]) - { - i++; - } - - // If there are no more digits it's the same number - if (i == span1Len) - { - continue; - } - - // Only need to compare the most significant digit - span1 = span1.Slice(i, 1); - span2 = span2.Slice(i, 1); - } - - if (!ulong.TryParse(span1, out var num1) - || !ulong.TryParse(span2, out var num2)) - { - return 0; - } - else if (num1 < num2) - { - return -1; - } - else if (num1 > num2) - { - return 1; - } } - else + + int result = span1.CompareTo(span2, StringComparison.InvariantCulture); + if (result != 0) { - int result = span1.CompareTo(span2, StringComparison.InvariantCulture); - if (result != 0) - { - return result; - } + return result; } } while (pos1 < len1 && pos2 < len2); diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 9fed8cbd9..4f80aa941 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.3"> + <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 000000000..2936fe4d6 --- /dev/null +++ b/src/Jellyfin.Extensions/Json/Converters/JsonBoolStringConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Linq; +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 97cbee971..4d56ca615 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 f30b63945..7c6124875 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -12,7 +12,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 32f80812a..3f4f55ee4 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.3"> + <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 b11bdc477..71572bcf6 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.3"> + <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 000000000..de8fc1bb8 --- /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 7c85ddd62..ad8a051fd 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 ee42216e4..6669a6689 100644 --- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs @@ -2,7 +2,8 @@ using System.Collections.Generic; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; -using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Api.Auth.FirstTimeSetupPolicy; using Jellyfin.Api.Constants; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; @@ -11,25 +12,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 +47,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 +65,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 7150c90bb..9cf8f8548 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 5b3d784ff..000000000 --- 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 ffe88fcde..000000000 --- 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 000000000..d6428fb2c --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs @@ -0,0 +1,36 @@ +using System; +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 c4640bd22..2d7741d81 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 c0e0d2b6b..015018910 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.3"> - <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 c74127f04..8fef7fde0 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.3"> - <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 1ddf5139c..54d93b48c 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.3"> - <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 dc4b58fec..69677ce42 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.3"> - <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 000000000..c9018fe2f --- /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/AlphanumericComparatorTests.cs b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs index 2a7e8fafd..105e2a52a 100644 --- a/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs +++ b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs @@ -19,6 +19,11 @@ namespace Jellyfin.Extensions.Tests [InlineData("12345678912345678912345678913234567891", "12345678912345678912345678913234567892")] [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891a")] [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891b")] + [InlineData("a5", "a11")] + [InlineData("a05a", "a5b")] + [InlineData("a5a", "a05b")] + [InlineData("6xxx", "007asdf")] + [InlineData("00042Q", "42s")] public void AlphanumericComparatorTest(params string?[] strings) { var copy = strings.Reverse().ToArray(); diff --git a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj index 16b18cc85..036489829 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.3"> - <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 000000000..be256da2e --- /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 c20f3dd99..eab003715 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.3"> - <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 5cfad93a6..894bec6aa 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.3"> - <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 1b27e344b..db7e91c6a 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 02bf046ed..89ba42da0 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 97dbb3be0..000000000 --- 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 f824b6f3b..6b703e741 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.3"> - <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 bbe1246ca..6cb98b2b8 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.IO; using System.Text.Json; using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; using MediaBrowser.MediaEncoding.Probing; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -15,9 +16,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)] @@ -31,16 +38,6 @@ namespace Jellyfin.MediaEncoding.Tests.Probing public void GetFrameRate_Success(string value, float? expected) => Assert.Equal(expected, ProbeResultNormalizer.GetFrameRate(value)); - [Theory] - [InlineData(0.5f, "0/1", false)] - [InlineData(24.5f, "8/196", false)] - [InlineData(63.5f, "1/127", true)] - [InlineData(null, "1/60", false)] - [InlineData(30f, "2/120", true)] - [InlineData(59.999996f, "1563/187560", true)] - public void IsCodecTimeBaseDoubleTheFrameRate_Success(float? frameRate, string codecTimeBase, bool expected) - => Assert.Equal(expected, ProbeResultNormalizer.IsCodecTimeBaseDoubleTheFrameRate(frameRate, codecTimeBase)); - [Fact] public void GetMediaInfo_MetaData_Success() { @@ -159,6 +156,112 @@ 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"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_progressive_no_field_order.mp4", MediaProtocol.File); + + Assert.Equal(2, res.MediaStreams.Count); + + Assert.NotNull(res.VideoStream); + Assert.Equal(res.MediaStreams[0], res.VideoStream); + Assert.Equal(0, res.VideoStream.Index); + Assert.Equal("h264", res.VideoStream.Codec); + Assert.Equal("Main", res.VideoStream.Profile); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + Assert.Equal(1080, res.VideoStream.Height); + Assert.Equal(1920, res.VideoStream.Width); + Assert.False(res.VideoStream.IsInterlaced); + Assert.Equal("16:9", res.VideoStream.AspectRatio); + Assert.Equal("yuv420p", res.VideoStream.PixelFormat); + Assert.Equal(41d, res.VideoStream.Level); + Assert.Equal(1, res.VideoStream.RefFrames); + Assert.True(res.VideoStream.IsAVC); + Assert.Equal(23.9760246f, res.VideoStream.RealFrameRate); + Assert.Equal("1/24000", res.VideoStream.TimeBase); + Assert.Equal(3948341, res.VideoStream.BitRate); + Assert.Equal(8, res.VideoStream.BitDepth); + Assert.True(res.VideoStream.IsDefault); + } + + [Fact] + public void GetMediaInfo_ProgressiveVideoNoFieldOrder2_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_progressive_no_field_order2.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_progressive_no_field_order2.mp4", MediaProtocol.File); + + Assert.Single(res.MediaStreams); + + Assert.NotNull(res.VideoStream); + Assert.Equal(res.MediaStreams[0], res.VideoStream); + Assert.Equal(0, res.VideoStream.Index); + Assert.Equal("h264", res.VideoStream.Codec); + Assert.Equal("High", res.VideoStream.Profile); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + Assert.Equal(720, res.VideoStream.Height); + Assert.Equal(1280, res.VideoStream.Width); + Assert.False(res.VideoStream.IsInterlaced); + Assert.Equal("16:9", res.VideoStream.AspectRatio); + Assert.Equal("yuv420p", res.VideoStream.PixelFormat); + Assert.Equal(31d, res.VideoStream.Level); + Assert.Equal(1, res.VideoStream.RefFrames); + Assert.True(res.VideoStream.IsAVC); + Assert.Equal(25f, res.VideoStream.RealFrameRate); + Assert.Equal("1/12800", res.VideoStream.TimeBase); + Assert.Equal(53288, res.VideoStream.BitRate); + Assert.Equal(8, res.VideoStream.BitDepth); + Assert.True(res.VideoStream.IsDefault); + } + + [Fact] + public void GetMediaInfo_InterlacedVideo_Success() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_interlaced.json"); + + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_interlaced.mp4", MediaProtocol.File); + + Assert.Single(res.MediaStreams); + + Assert.NotNull(res.VideoStream); + Assert.Equal(res.MediaStreams[0], res.VideoStream); + Assert.Equal(0, res.VideoStream.Index); + Assert.Equal("h264", res.VideoStream.Codec); + Assert.Equal("High", res.VideoStream.Profile); + Assert.Equal(MediaStreamType.Video, res.VideoStream.Type); + Assert.Equal(720, res.VideoStream.Height); + Assert.Equal(1280, res.VideoStream.Width); + Assert.True(res.VideoStream.IsInterlaced); + Assert.Equal("16:9", res.VideoStream.AspectRatio); + Assert.Equal("yuv420p", res.VideoStream.PixelFormat); + Assert.Equal(40d, res.VideoStream.Level); + Assert.Equal(1, res.VideoStream.RefFrames); + Assert.True(res.VideoStream.IsAVC); + Assert.Equal(25f, res.VideoStream.RealFrameRate); + Assert.Equal("1/12800", res.VideoStream.TimeBase); + Assert.Equal(56945, res.VideoStream.BitRate); + Assert.Equal(8, res.VideoStream.BitDepth); + Assert.True(res.VideoStream.IsDefault); + } + + [Fact] public void GetMediaInfo_MusicVideo_Success() { var bytes = File.ReadAllBytes("Test Data/Probing/music_video_metadata.json"); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_interlaced.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_interlaced.json new file mode 100644 index 000000000..810244920 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_interlaced.json @@ -0,0 +1,81 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1280, + "height": 720, + "coded_width": 1280, + "coded_height": 720, + "closed_captions": 0, + "film_grain": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 40, + "chroma_location": "left", + "field_order": "tt", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "id": "0x1", + "r_frame_rate": "25/1", + "avg_frame_rate": "25/1", + "time_base": "1/12800", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3840000, + "duration": "300.000000", + "bit_rate": "56945", + "bits_per_raw_sample": "8", + "nb_frames": "7500", + "extradata_size": 42, + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler", + "vendor_id": "[0][0][0][0]" + } + } + ], + "format": { + "filename": "test-gray.720i.mp4", + "nb_streams": 1, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "2223957", + "bit_rate": "59305", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_progressive_no_field_order.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_progressive_no_field_order.json new file mode 100644 index 000000000..897c5e3ab --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_progressive_no_field_order.json @@ -0,0 +1,133 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "Main", + "codec_type": "video", + "codec_time_base": "1001/48000", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1920, + "height": 1080, + "coded_width": 1920, + "coded_height": 1088, + "closed_captions": 0, + "has_b_frames": 1, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "16:9", + "pix_fmt": "yuv420p", + "level": 41, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "time_base": "1/24000", + "start_pts": 1000, + "start_time": "0.041667", + "duration_ts": 29095066, + "duration": "1212.294417", + "bit_rate": "3948341", + "bits_per_raw_sample": "8", + "nb_frames": "29066", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2020-01-20T13:56:34.000000Z", + "language": "eng", + "handler_name": "\fVideoHandler", + "encoder": "h264" + } + }, + { + "index": 1, + "codec_name": "ac3", + "codec_long_name": "ATSC A/52A (AC-3)", + "codec_type": "audio", + "codec_time_base": "1/48000", + "codec_tag_string": "ac-3", + "codec_tag": "0x332d6361", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "dmix_mode": "-1", + "ltrt_cmixlev": "-1.000000", + "ltrt_surmixlev": "-1.000000", + "loro_cmixlev": "-1.000000", + "loro_surmixlev": "-1.000000", + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/48000", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 58232832, + "duration": "1213.184000", + "bit_rate": "224000", + "nb_frames": "37912", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "creation_time": "2020-01-20T13:56:34.000000Z", + "language": "eng", + "handler_name": "\fSoundHandler" + }, + "side_data_list": [ + { + "side_data_type": "Audio Service Type" + } + ] + } + ], + "format": { + "filename": "The Big Bang Theory - S01E17.mp4", + "nb_streams": 2, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "1213.184000", + "size": "633084606", + "bit_rate": "4174698", + "probe_score": 100, + "tags": { + "major_brand": "mp42", + "minor_version": "512", + "compatible_brands": "mp42", + "creation_time": "2020-01-20T13:56:34.000000Z", + "media_type": "9", + "season_number": "0", + "episode_sort": "0", + "hd_video": "0", + "iTunMOVI": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><!DOCTYPE plist PUBLIC \"-//Apple Computer//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"><plist version=\"1.0\"><dict><key>studio</key><string>studio</string><key>cast</key><array><dict><key>name</key><string></string></dict></array><key>directors</key><array><dict><key>name</key><string></string></dict></array><key>producers</key><array><dict><key>name</key><string></string></dict></array><key>codirectors</key><array><dict><key>name</key><string>codirector</string></dict></array><key>screenwriters</key><array><dict><key>name</key><string></string></dict></array></dict></plist>" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_progressive_no_field_order2.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_progressive_no_field_order2.json new file mode 100644 index 000000000..4a03e0d61 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_progressive_no_field_order2.json @@ -0,0 +1,72 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_time_base": "1/50", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1280, + "height": 720, + "coded_width": 1280, + "coded_height": 720, + "closed_captions": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 31, + "chroma_location": "left", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "r_frame_rate": "25/1", + "avg_frame_rate": "25/1", + "time_base": "1/12800", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 3840000, + "duration": "300.000000", + "bit_rate": "53288", + "bits_per_raw_sample": "8", + "nb_frames": "7500", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler" + } + } + ], + "format": { + "filename": "test-gray.720p.mp4", + "nb_streams": 1, + "nb_programs": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "300.000000", + "size": "2086818", + "bit_rate": "55648", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf58.20.100" + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/ffprobe1.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts.json index cdad5df50..cdad5df50 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 60be17a74..f05a0152e 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -26,7 +26,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] + [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -37,7 +37,7 @@ namespace Jellyfin.Model.Tests [InlineData("Firefox", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] + [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -88,7 +88,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Chrome-NoHLS", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] - [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] + [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -176,7 +176,7 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] + [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -186,7 +186,7 @@ namespace Jellyfin.Model.Tests [InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] + [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported)] // #6450 [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -275,10 +275,10 @@ namespace Jellyfin.Model.Tests // Chrome [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 - [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] + [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Firefox [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] + [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index b6578a7f1..8345b610e 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.3"> - <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 cbab455f0..371c3811a 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 58aaed023..c49663248 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 f10f9159d..112dd780e 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.3"> - <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 68059f980..406381f14 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 af219b118..7604ddc80 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 287d881a8..294f11ee7 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.Networking.Tests/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj index 3a39daa36..4b4bdd2a5 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.3"> - <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 61f913252..df2a2ca70 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 6cc998d27..c12f0cd68 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.3"> - <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.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 82628d733..9b6cb40b0 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.3"> - <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 000000000..d136c1bc6 --- /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 286ba0405..6d0ed7bbb 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/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs index efc3ac0c2..aed584355 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/LiveTv/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs index 82ce8fc4e..92b4178fd 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 16eb7a75c..7fabe9904 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/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 000000000..dd4aa8977 --- /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.Integration.Tests/AuthHelper.cs b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs index 9eb0beda4..3737fee0a 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 000000000..078002994 --- /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 000000000..013d19a9f --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs @@ -0,0 +1,40 @@ +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); + } +} 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 000000000..17f3dc99f --- /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 000000000..868ecd53f --- /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 000000000..cb0a829e8 --- /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 2b825a93a..2a3c53dbe 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 000000000..69f2ccf33 --- /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 000000000..0f9a2e90a --- /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 006b38a11..a5296d8c9 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.3"> - <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.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 1bfa5996d..55bc43455 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -4,6 +4,8 @@ using System.Globalization; using System.IO; using System.Threading; using Emby.Server.Implementations; +using Jellyfin.Server.Extensions; +using Jellyfin.Server.Helpers; using MediaBrowser.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -11,6 +13,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Extensions.Logging; @@ -33,7 +36,7 @@ namespace Jellyfin.Server.Integration.Tests Log.Logger = new LoggerConfiguration() .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) .CreateLogger(); - Program.PerformStaticInitialization(); + StartupHelpers.PerformStaticInitialization(); } /// <inheritdoc/> @@ -63,7 +66,7 @@ namespace Jellyfin.Server.Integration.Tests // Create the logging config file // TODO: We shouldn't need to do this since we are only logging to console - Program.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult(); + StartupHelpers.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult(); // Create a copy of the application configuration to use for startup var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths); @@ -81,7 +84,7 @@ namespace Jellyfin.Server.Integration.Tests _disposableComponents.Add(appHost); builder.ConfigureServices(services => appHost.Init(services)) - .ConfigureWebHostBuilder(appHost, startupConfig, appPaths) + .ConfigureWebHostBuilder(appHost, startupConfig, appPaths, NullLogger.Instance) .ConfigureAppConfiguration((context, builder) => { builder diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj index 771fad635..5fea805ae 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.3"> - <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.Server.Tests/UrlDecodeQueryFeatureTests.cs b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs index d15c9d6f5..797fc8f64 100644 --- a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs +++ b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Linq; -using Jellyfin.Server.Middleware; +using Jellyfin.Api.Middleware; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj index 0d69c3f61..9fe0744de 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.3"> - <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> |
